From 4c98c5a657594015957ce30a1d25677e7dd77218 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Sat, 14 Feb 2026 01:29:34 +0100 Subject: [PATCH] style: apply cargo fmt to entire codebase Standardize code formatting across all 173 Rust source files using rustfmt. No functional changes - purely cosmetic. This establishes a consistent code style baseline for the project going forward. --- src/application/adapters/caldav_adapter.rs | 555 +++--- .../adapters/caldav_adapter_test.rs | 625 +++--- src/application/adapters/carddav_adapter.rs | 1334 +++++++------ .../adapters/carddav_adapter_test.rs | 956 +++++---- src/application/adapters/mod.rs | 2 +- src/application/adapters/webdav_adapter.rs | 595 +++--- src/application/dtos/address_book_dto.rs | 4 +- src/application/dtos/calendar_dto.rs | 8 +- src/application/dtos/contact_dto.rs | 4 +- src/application/dtos/favorites_dto.rs | 12 +- src/application/dtos/file_dto.rs | 24 +- src/application/dtos/folder_dto.rs | 22 +- src/application/dtos/i18n_dto.rs | 18 +- src/application/dtos/mod.rs | 1 - src/application/dtos/pagination.rs | 35 +- src/application/dtos/recent_dto.rs | 12 +- src/application/dtos/search_dto.rs | 46 +- src/application/dtos/settings_dto.rs | 260 +-- src/application/dtos/share_dto.rs | 4 +- src/application/dtos/trash_dto.rs | 2 +- src/application/dtos/user_dto.rs | 6 +- src/application/mod.rs | 4 +- src/application/ports/auth_ports.rs | 90 +- src/application/ports/cache_ports.rs | 4 +- src/application/ports/calendar_ports.rs | 241 ++- src/application/ports/carddav_ports.rs | 154 +- src/application/ports/chunked_upload_ports.rs | 19 +- src/application/ports/compression_ports.rs | 8 +- src/application/ports/dedup_ports.rs | 4 +- src/application/ports/favorites_ports.rs | 19 +- src/application/ports/file_ports.rs | 43 +- src/application/ports/inbound.rs | 35 +- src/application/ports/mod.rs | 2 +- src/application/ports/outbound.rs | 24 +- src/application/ports/recent_ports.rs | 28 +- src/application/ports/share_ports.rs | 56 +- src/application/ports/storage_ports.rs | 25 +- src/application/ports/thumbnail_ports.rs | 18 +- src/application/ports/transcode_ports.rs | 2 +- src/application/ports/trash_ports.rs | 10 +- src/application/ports/zip_ports.rs | 2 +- .../services/admin_settings_service.rs | 700 ++++--- .../services/auth_application_service.rs | 700 ++++--- src/application/services/batch_operations.rs | 337 ++-- src/application/services/calendar_service.rs | 554 ++++-- src/application/services/contact_service.rs | 944 ++++++--- src/application/services/favorites_service.rs | 60 +- .../services/file_management_service.rs | 60 +- .../services/file_retrieval_service.rs | 147 +- .../services/file_upload_service.rs | 91 +- .../services/file_use_case_factory.rs | 22 +- src/application/services/folder_service.rs | 327 +-- .../services/i18n_application_service.rs | 34 +- src/application/services/mod.rs | 4 +- src/application/services/recent_service.rs | 67 +- src/application/services/search_service.rs | 360 ++-- src/application/services/share_service.rs | 266 ++- src/application/services/storage_mediator.rs | 227 ++- .../services/storage_usage_service.rs | 105 +- src/application/services/trash_service.rs | 427 ++-- .../services/trash_service_test.rs | 407 ++-- src/application/transactions/mod.rs | 2 +- .../transactions/storage_transaction.rs | 78 +- src/bin/migrate.rs | 22 +- src/common/config.rs | 238 ++- src/common/di.rs | 582 +++--- src/common/mod.rs | 4 +- src/common/stubs.rs | 161 +- src/domain/entities/calendar.rs | 101 +- src/domain/entities/calendar_event.rs | 390 ++-- src/domain/entities/contact.rs | 341 +++- src/domain/entities/entity_errors.rs | 512 ++--- src/domain/entities/file.rs | 109 +- src/domain/entities/folder.rs | 93 +- src/domain/entities/mod.rs | 12 +- src/domain/entities/session.rs | 20 +- src/domain/entities/share.rs | 58 +- src/domain/entities/trashed_item.rs | 2 +- src/domain/entities/user.rs | 70 +- src/domain/errors.rs | 538 +++-- .../repositories/address_book_repository.rs | 47 +- .../repositories/calendar_event_repository.rs | 81 +- .../repositories/calendar_repository.rs | 106 +- src/domain/repositories/contact_repository.rs | 49 +- src/domain/repositories/file_repository.rs | 17 +- src/domain/repositories/folder_repository.rs | 42 +- src/domain/repositories/mod.rs | 6 +- src/domain/repositories/session_repository.rs | 40 +- .../repositories/settings_repository.rs | 55 +- src/domain/repositories/share_repository.rs | 19 +- src/domain/repositories/trash_repository.rs | 4 +- src/domain/repositories/user_repository.rs | 86 +- src/domain/services/i18n_service.rs | 16 +- src/domain/services/mod.rs | 2 +- src/domain/services/path_service.rs | 48 +- .../adapters/calendar_storage_adapter.rs | 765 ++++--- .../adapters/contact_storage_adapter.rs | 1654 +++++++++------- src/infrastructure/adapters/error_adapters.rs | 253 +-- src/infrastructure/adapters/mod.rs | 32 +- src/infrastructure/auth_factory.rs | 37 +- src/infrastructure/db.rs | 169 +- src/infrastructure/mod.rs | 1 - .../repositories/composite_file_repository.rs | 30 +- .../repositories/file_fs_read_repository.rs | 265 ++- .../repositories/file_fs_write_repository.rs | 477 +++-- .../repositories/folder_fs_repository.rs | 861 ++++---- .../folder_fs_repository_trash.rs | 109 +- src/infrastructure/repositories/mod.rs | 8 +- .../repositories/parallel_file_processor.rs | 359 ++-- .../pg/address_book_pg_repository.rs | 209 +- .../pg/calendar_event_pg_repository.rs | 205 +- .../repositories/pg/calendar_pg_repository.rs | 214 +- .../pg/contact_group_pg_repository.rs | 499 ++--- .../pg/contact_persistence_dto.rs | 258 +-- .../repositories/pg/contact_pg_repository.rs | 82 +- .../pg/favorites_pg_repository.rs | 28 +- src/infrastructure/repositories/pg/mod.rs | 8 +- .../pg/recent_items_pg_repository.rs | 34 +- .../repositories/pg/session_pg_repository.rs | 277 +-- .../repositories/pg/settings_pg_repository.rs | 190 +- .../repositories/pg/transaction_utils.rs | 21 +- .../repositories/pg/user_pg_repository.rs | 346 ++-- .../repositories/repository_errors.rs | 54 +- .../repositories/share_fs_repository.rs | 97 +- .../repositories/trash_fs_repository.rs | 343 ++-- src/infrastructure/services/buffer_pool.rs | 197 +- .../services/chunked_upload_service.rs | 1291 ++++++------ .../services/compression_service.rs | 260 ++- src/infrastructure/services/dedup_service.rs | 1749 +++++++++-------- .../services/file_content_cache.rs | 664 ++++--- .../services/file_metadata_cache.rs | 313 +-- .../services/file_system_i18n_service.rs | 91 +- .../services/file_system_utils.rs | 234 ++- .../services/id_mapping_optimizer.rs | 303 +-- .../services/id_mapping_service.rs | 492 +++-- .../services/image_transcode_service.rs | 943 ++++----- src/infrastructure/services/jwt_service.rs | 431 ++-- src/infrastructure/services/mod.rs | 30 +- src/infrastructure/services/oidc_service.rs | 980 ++++----- .../services/password_hasher.rs | 206 +- src/infrastructure/services/path_service.rs | 641 +++--- .../services/thumbnail_service.rs | 836 ++++---- .../services/trash_cleanup_service.rs | 48 +- .../services/write_behind_cache.rs | 984 +++++----- src/infrastructure/services/zip_service.rs | 124 +- src/interfaces/api/handlers/admin_handler.rs | 1028 +++++----- src/interfaces/api/handlers/auth_handler.rs | 260 ++- src/interfaces/api/handlers/batch_handler.rs | 147 +- src/interfaces/api/handlers/caldav_handler.rs | 326 +-- .../api/handlers/carddav_handler.rs | 340 ++-- .../api/handlers/chunked_upload_handler.rs | 655 +++--- src/interfaces/api/handlers/dedup_handler.rs | 869 ++++---- .../api/handlers/favorites_handler.rs | 61 +- src/interfaces/api/handlers/file_handler.rs | 587 +++--- src/interfaces/api/handlers/folder_handler.rs | 223 ++- src/interfaces/api/handlers/i18n_handler.rs | 74 +- src/interfaces/api/handlers/mod.rs | 22 +- src/interfaces/api/handlers/recent_handler.rs | 99 +- src/interfaces/api/handlers/search_handler.rs | 125 +- src/interfaces/api/handlers/share_handler.rs | 56 +- src/interfaces/api/handlers/trash_handler.rs | 323 +-- src/interfaces/api/handlers/webdav_handler.rs | 694 ++++--- src/interfaces/api/mod.rs | 2 +- src/interfaces/api/routes.rs | 192 +- src/interfaces/errors.rs | 256 +-- src/interfaces/middleware/auth.rs | 67 +- src/interfaces/middleware/cache.rs | 309 +-- src/interfaces/middleware/mod.rs | 4 +- src/interfaces/middleware/redirect.rs | 53 +- src/interfaces/mod.rs | 4 +- src/interfaces/web/mod.rs | 16 +- src/lib.rs | 10 +- src/main.rs | 351 ++-- 173 files changed, 23368 insertions(+), 17590 deletions(-) diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index d5183743..1313b732 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -1,16 +1,20 @@ +use chrono::{DateTime, Utc}; +use quick_xml::{ + Reader, Writer, + events::{BytesEnd, BytesStart, BytesText, Event}, +}; /** * CalDAV Adapter Module - * + * * This module provides conversion between CalDAV protocol XML structures and OxiCloud domain objects. * It handles parsing CalDAV request XML and generating CalDAV response XML according to RFC 4791. */ - -use std::io::{Read, Write, BufReader}; -use chrono::{DateTime, Utc}; -use quick_xml::{Reader, Writer, events::{Event, BytesStart, BytesEnd, BytesText}}; +use std::io::{BufReader, Read, Write}; use uuid::Uuid; -use crate::application::adapters::webdav_adapter::{WebDavAdapter, QualifiedName, PropFindType, PropFindRequest, Result, WebDavError}; +use crate::application::adapters::webdav_adapter::{ + PropFindRequest, PropFindType, QualifiedName, Result, WebDavAdapter, WebDavError, +}; use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto}; /// CalDAV report type @@ -30,7 +34,7 @@ pub enum CalDavReportType { SyncCollection { sync_token: String, props: Vec, - } + }, } /// CalDAV adapter for converting between XML and domain objects @@ -41,7 +45,7 @@ impl CalDavAdapter { 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_calendar_query = false; let mut in_calendar_multiget = false; @@ -53,26 +57,33 @@ impl CalDavAdapter { let mut props = Vec::new(); let mut hrefs = Vec::new(); let mut sync_token = String::new(); - + 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 == "calendar-query" || s.ends_with(":calendar-query") => in_calendar_query = true, - s if s == "calendar-multiget" || s.ends_with(":calendar-multiget") => in_calendar_multiget = true, - s if s == "sync-collection" || s.ends_with(":sync-collection") => in_sync_collection = true, + s if s == "calendar-query" || s.ends_with(":calendar-query") => { + in_calendar_query = true + } + s if s == "calendar-multiget" || s.ends_with(":calendar-multiget") => { + in_calendar_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 == "filter" || s.ends_with(":filter") => in_filter = true, s if s == "time-range" || s.ends_with(":time-range") => { // Parse time-range attributes for attr in e.attributes() { if let Ok(attr) = attr { - let attr_name = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); + let attr_name = + std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); let attr_value = attr.unescape_value().unwrap_or_default(); - + if attr_name == "start" { // Parse ISO date format with Z for UTC start_time = DateTime::parse_from_rfc3339(&attr_value) @@ -85,65 +96,67 @@ impl CalDavAdapter { } } } - }, + } s if s == "sync-token" || s.ends_with(":sync-token") => { // We'll capture the text in the Text event - }, + } s if s == "href" || s.ends_with(":href") => { // We'll capture the text in the Text event - }, + } _ if in_prop => { // Add property to request let namespace = WebDavAdapter::extract_namespace(name_str); let prop_name = WebDavAdapter::extract_local_name(name_str); - + props.push(QualifiedName::new(namespace, prop_name)); - }, + } _ => { /* Ignore other elements */ } } - }, + } Ok(Event::Text(e)) => { let text = e.decode().unwrap_or_default(); - + // Check if we're in sync-token element if in_sync_collection && !in_prop && !in_filter { sync_token = text.to_string(); } - + // Check if we're in href element if (in_calendar_multiget || in_sync_collection) && !in_prop && !in_filter { hrefs.push(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 { // 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 */ }, - _ => () + s if s == "time-range" || s.ends_with(":time-range") => { /* time-range end, attributes already parsed */ + } + _ => (), } - }, + } Ok(Event::Empty(ref e)) => { let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); - + if in_prop { // Add empty property element to request let namespace = WebDavAdapter::extract_namespace(name_str); let prop_name = WebDavAdapter::extract_local_name(name_str); - + props.push(QualifiedName::new(namespace, prop_name)); } else if name_str == "time-range" || name_str.ends_with(":time-range") { // Parse time-range attributes for attr in e.attributes() { if let Ok(attr) = attr { - let attr_name = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); + let attr_name = + std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); let attr_value = attr.unescape_value().unwrap_or_default(); - + if attr_name == "start" { // Parse ISO date format with Z for UTC start_time = DateTime::parse_from_rfc3339(&attr_value) @@ -157,15 +170,15 @@ impl CalDavAdapter { } } } - }, + } Ok(Event::Eof) => break, Err(e) => return Err(WebDavError::XmlError(e)), _ => (), } - + buffer.clear(); } - + // Create the appropriate report type based on what we parsed let report_type = if in_calendar_query { // If both start and end time are present, create a time range @@ -174,21 +187,12 @@ impl CalDavAdapter { } else { None }; - - CalDavReportType::CalendarQuery { - time_range, - props, - } + + CalDavReportType::CalendarQuery { time_range, props } } else if in_calendar_multiget { - CalDavReportType::CalendarMultiget { - hrefs, - props, - } + CalDavReportType::CalendarMultiget { hrefs, props } } else if in_sync_collection { - CalDavReportType::SyncCollection { - sync_token, - props, - } + CalDavReportType::SyncCollection { sync_token, props } } else { // Default to empty calendar query CalDavReportType::CalendarQuery { @@ -196,10 +200,10 @@ impl CalDavAdapter { props, } }; - + Ok(report_type) } - + /// Generate a PROPFIND response for calendars pub fn generate_calendars_propfind_response( writer: W, @@ -208,25 +212,32 @@ impl CalDavAdapter { base_href: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); - + // Start multistatus response - 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/"), - ])))?; - + 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/"), + ]), + ))?; + // Add responses for calendars for calendar in calendars { - Self::write_calendar_response(&mut xml_writer, calendar, request, &format!("{}{}/", base_href, calendar.id))?; + Self::write_calendar_response( + &mut xml_writer, + calendar, + request, + &format!("{}{}/", base_href, calendar.id), + )?; } - + // End multistatus xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - + Ok(()) } - + /// Write calendar properties as a response fn write_calendar_response( xml_writer: &mut Writer, @@ -236,126 +247,139 @@ impl CalDavAdapter { ) -> Result<()> { // Start response element xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; - + // Write href 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")))?; - + // Write propstat xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - + // Start prop xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - + // Write properties based on request type match &request.prop_find_type { PropFindType::AllProp => { // Write all standard properties for a calendar Self::write_calendar_standard_props(xml_writer, calendar)?; - }, + } PropFindType::PropName => { // Write only property names (empty elements) Self::write_calendar_prop_names(xml_writer)?; - }, + } PropFindType::Prop(props) => { // Write requested properties Self::write_calendar_requested_props(xml_writer, calendar, props)?; } } - + // End prop xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + // Write status 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")))?; - + // End propstat xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; - + // End response xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; - + Ok(()) } - + /// Write standard calendar properties fn write_calendar_standard_props( xml_writer: &mut Writer, calendar: &CalendarDto, ) -> Result<()> { // Common WebDAV properties - + // Resource type (collection + calendar) 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("C:calendar")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; - + // Display name xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; xml_writer.write_event(Event::Text(BytesText::new(&calendar.name)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; - + // Last modified xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - xml_writer.write_event(Event::Text(BytesText::new(&calendar.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::Text(BytesText::new( + &calendar.updated_at.to_rfc2822(), + )))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - + // ETag xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", calendar.id))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; - + // Content type for calendar collection xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; - xml_writer.write_event(Event::Text(BytesText::new("text/calendar; component=VCALENDAR")))?; + xml_writer.write_event(Event::Text(BytesText::new( + "text/calendar; component=VCALENDAR", + )))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - + // CalDAV specific properties - + // Supported calendar component set - xml_writer.write_event(Event::Start(BytesStart::new("C:supported-calendar-component-set")))?; - xml_writer.write_event(Event::Empty(BytesStart::new("C:comp").with_attributes([("name", "VEVENT")])))?; - xml_writer.write_event(Event::End(BytesEnd::new("C:supported-calendar-component-set")))?; - + xml_writer.write_event(Event::Start(BytesStart::new( + "C:supported-calendar-component-set", + )))?; + xml_writer.write_event(Event::Empty( + BytesStart::new("C:comp").with_attributes([("name", "VEVENT")]), + ))?; + xml_writer.write_event(Event::End(BytesEnd::new( + "C:supported-calendar-component-set", + )))?; + // Calendar timezone (empty for UTC) xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-timezone")))?; - + // Calendar color if let Some(color) = &calendar.color { xml_writer.write_event(Event::Start(BytesStart::new("CS:calendar-color")))?; xml_writer.write_event(Event::Text(BytesText::new(color)))?; xml_writer.write_event(Event::End(BytesEnd::new("CS:calendar-color")))?; } - + // Support calendar-access (RFC4791) xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-access")))?; - + // 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: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")))?; - + // Only add write privilege if user owns the calendar or has write access - if calendar.owner_id == "current_user_id" { // This should be replaced with actual user check + if calendar.owner_id == "current_user_id" { + // This should be replaced with actual user check 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")))?; - + // Calendar description if present if let Some(desc) = &calendar.description { xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-description")))?; xml_writer.write_event(Event::Text(BytesText::new(desc)))?; xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-description")))?; } - + // Custom properties for (name, value) in &calendar.custom_properties { // Skip properties that start with _ - they're internal @@ -365,32 +389,34 @@ impl CalDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new(format!("CS:{}", name))))?; } } - + Ok(()) } - + /// Write calendar property names - fn write_calendar_prop_names( - xml_writer: &mut Writer, - ) -> Result<()> { + fn write_calendar_prop_names(xml_writer: &mut Writer) -> Result<()> { // Common WebDAV property names 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")))?; - + // CalDAV specific property names - xml_writer.write_event(Event::Empty(BytesStart::new("C:supported-calendar-component-set")))?; + xml_writer.write_event(Event::Empty(BytesStart::new( + "C:supported-calendar-component-set", + )))?; xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-timezone")))?; xml_writer.write_event(Event::Empty(BytesStart::new("CS:calendar-color")))?; xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-access")))?; - xml_writer.write_event(Event::Empty(BytesStart::new("D:current-user-privilege-set")))?; + xml_writer.write_event(Event::Empty(BytesStart::new( + "D:current-user-privilege-set", + )))?; xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-description")))?; - + Ok(()) } - + /// Write requested calendar properties fn write_calendar_requested_props( xml_writer: &mut Writer, @@ -405,76 +431,98 @@ impl CalDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar")))?; 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(&calendar.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(&calendar.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::Text(BytesText::new( + &calendar.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!("\"{}\"", calendar.id))))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + calendar.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/calendar; component=VCALENDAR")))?; + xml_writer.write_event(Event::Text(BytesText::new( + "text/calendar; component=VCALENDAR", + )))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - }, + } ("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: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")))?; - + // Only add write privilege if user owns the calendar or has write access - if calendar.owner_id == "current_user_id" { // This should be replaced with actual user check + if calendar.owner_id == "current_user_id" { + // This should be replaced with actual user check 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")))?; - }, - + + xml_writer + .write_event(Event::End(BytesEnd::new("D:current-user-privilege-set")))?; + } + // CalDAV namespace properties ("urn:ietf:params:xml:ns:caldav", "supported-calendar-component-set") => { - xml_writer.write_event(Event::Start(BytesStart::new("C:supported-calendar-component-set")))?; - xml_writer.write_event(Event::Empty(BytesStart::new("C:comp").with_attributes([("name", "VEVENT")])))?; - xml_writer.write_event(Event::End(BytesEnd::new("C:supported-calendar-component-set")))?; - }, + xml_writer.write_event(Event::Start(BytesStart::new( + "C:supported-calendar-component-set", + )))?; + xml_writer.write_event(Event::Empty( + BytesStart::new("C:comp").with_attributes([("name", "VEVENT")]), + ))?; + xml_writer.write_event(Event::End(BytesEnd::new( + "C:supported-calendar-component-set", + )))?; + } ("urn:ietf:params:xml:ns:caldav", "calendar-timezone") => { xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-timezone")))?; - }, + } ("urn:ietf:params:xml:ns:caldav", "calendar-access") => { xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-access")))?; - }, + } ("urn:ietf:params:xml:ns:caldav", "calendar-description") => { if let Some(desc) = &calendar.description { - xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-description")))?; + xml_writer + .write_event(Event::Start(BytesStart::new("C:calendar-description")))?; xml_writer.write_event(Event::Text(BytesText::new(desc)))?; - xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-description")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("C:calendar-description")))?; } else { - xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-description")))?; + xml_writer + .write_event(Event::Empty(BytesStart::new("C:calendar-description")))?; } - }, - + } + // CalendarServer namespace properties ("http://calendarserver.org/ns/", "calendar-color") => { if let Some(color) = &calendar.color { - xml_writer.write_event(Event::Start(BytesStart::new("CS:calendar-color")))?; + xml_writer + .write_event(Event::Start(BytesStart::new("CS:calendar-color")))?; xml_writer.write_event(Event::Text(BytesText::new(color)))?; xml_writer.write_event(Event::End(BytesEnd::new("CS:calendar-color")))?; } else { - xml_writer.write_event(Event::Empty(BytesStart::new("CS:calendar-color")))?; + xml_writer + .write_event(Event::Empty(BytesStart::new("CS:calendar-color")))?; } - }, - + } + // Custom properties from the calendar _ => { // Check if it's a custom property @@ -488,7 +536,7 @@ impl CalDavAdapter { } else { format!("{}:{}", prop.namespace, prop.name) }; - + xml_writer.write_event(Event::Start(BytesStart::new(&prop_name)))?; xml_writer.write_event(Event::Text(BytesText::new(value)))?; xml_writer.write_event(Event::End(BytesEnd::new(&prop_name)))?; @@ -503,16 +551,16 @@ impl CalDavAdapter { } else { format!("{}:{}", prop.namespace, prop.name) }; - + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; } } } } - + Ok(()) } - + /// Generate PROPFIND response for a single calendar collection + its events pub fn generate_calendar_collection_propfind( writer: W, @@ -523,63 +571,69 @@ impl CalDavAdapter { 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/"), - ])))?; - + + 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::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::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::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, @@ -588,36 +642,38 @@ impl CalDavAdapter { base_href: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); - + // Start multistatus response - 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/"), - ])))?; - + 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/"), + ]), + ))?; + // Determine which properties to include based on request type let props = match request { CalDavReportType::CalendarQuery { props, .. } => props.clone(), CalDavReportType::CalendarMultiget { props, .. } => props.clone(), CalDavReportType::SyncCollection { props, .. } => props.clone(), }; - + // Add responses for events for event in events { // Create the event href based on its UID let href = format!("{}{}.ics", base_href, event.ical_uid); - + // Write event response Self::write_event_response(&mut xml_writer, event, &props, &href)?; } - + // End multistatus xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - + Ok(()) } - + /// Write event properties as a response fn write_event_response( xml_writer: &mut Writer, @@ -627,18 +683,18 @@ impl CalDavAdapter { ) -> Result<()> { // Start response element xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; - + // Write href 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")))?; - + // Write propstat xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - + // Start prop xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - + // If no specific props requested, return all common ones if props.is_empty() { Self::write_event_standard_props(xml_writer, event)?; @@ -646,51 +702,53 @@ impl CalDavAdapter { // Write specifically requested properties Self::write_event_requested_props(xml_writer, event, props)?; } - + // End prop xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + // Write status 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")))?; - + // End propstat xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; - + // End response xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; - + Ok(()) } - + /// Write standard event properties fn write_event_standard_props( xml_writer: &mut Writer, event: &CalendarEventDto, ) -> Result<()> { // Common WebDAV properties - + // Resource type (empty for non-collection) xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; - + // ETag based on updated_at timestamp 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")))?; - + // Content type 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::Text(BytesText::new( + "text/calendar; component=VEVENT", + )))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - + // Last modified 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")))?; - + // CalDAV specific properties - + // Calendar data (iCalendar format) xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-data")))?; // In a full implementation, we would generate a complete iCalendar component here @@ -712,15 +770,18 @@ impl CalDavAdapter { 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"), - event.rrule.as_ref().map_or("".to_string(), |r| format!("RRULE:{}\r\n", r)), + event + .rrule + .as_ref() + .map_or("".to_string(), |r| format!("RRULE:{}\r\n", r)), event.updated_at.format("%Y%m%dT%H%M%SZ"), ); xml_writer.write_event(Event::Text(BytesText::new(&ical_data)))?; xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-data")))?; - + Ok(()) } - + /// Write requested event properties fn write_event_requested_props( xml_writer: &mut Writer, @@ -732,23 +793,27 @@ impl CalDavAdapter { // DAV namespace properties ("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!("\"{}\"", event.id))))?; + xml_writer + .write_event(Event::Text(BytesText::new(&format!("\"{}\"", event.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/calendar; component=VEVENT")))?; + xml_writer.write_event(Event::Text(BytesText::new( + "text/calendar; component=VEVENT", + )))?; 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(&event.updated_at.to_rfc2822())))?; + xml_writer + .write_event(Event::Text(BytesText::new(&event.updated_at.to_rfc2822())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - }, - + } + // CalDAV namespace properties ("urn:ietf:params:xml:ns:caldav", "calendar-data") => { xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-data")))?; @@ -771,13 +836,16 @@ impl CalDavAdapter { 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"), - event.rrule.as_ref().map_or("".to_string(), |r| format!("RRULE:{}\r\n", r)), + event + .rrule + .as_ref() + .map_or("".to_string(), |r| format!("RRULE:{}\r\n", r)), event.updated_at.format("%Y%m%dT%H%M%SZ"), ); xml_writer.write_event(Event::Text(BytesText::new(&ical_data)))?; xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-data")))?; - }, - + } + // Property not supported _ => { // Write empty element @@ -790,20 +858,22 @@ impl CalDavAdapter { } else { format!("{}:{}", prop.namespace, prop.name) }; - + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; } } } - + Ok(()) } - + /// Parse a MKCALENDAR XML request - pub fn parse_mkcalendar(reader: R) -> Result<(String, Option, Option)> { + pub fn parse_mkcalendar( + 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_mkcalendar = false; let mut in_set = false; @@ -811,30 +881,43 @@ impl CalDavAdapter { let mut in_displayname = false; let mut in_description = false; let mut in_calendar_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 == "mkcalendar" || s.ends_with(":mkcalendar") => in_mkcalendar = true, + s if s == "mkcalendar" || s.ends_with(":mkcalendar") => { + in_mkcalendar = true + } s if in_mkcalendar && (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 == "calendar-description" || s.ends_with(":calendar-description")) => in_description = true, - s if in_prop && (s == "calendar-color" || s.ends_with(":calendar-color")) => in_calendar_color = true, - _ => () + s if in_prop && (s == "displayname" || s.ends_with(":displayname")) => { + in_displayname = true + } + s if in_prop + && (s == "calendar-description" + || s.ends_with(":calendar-description")) => + { + in_description = true + } + s if in_prop + && (s == "calendar-color" || s.ends_with(":calendar-color")) => + { + in_calendar_color = true + } + _ => (), } - }, + } Ok(Event::Text(e)) => { let text = e.decode().unwrap_or_default(); - + if in_displayname { displayname = text.to_string(); } else if in_description { @@ -842,34 +925,44 @@ impl CalDavAdapter { } else if in_calendar_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 == "mkcalendar" || s.ends_with(":mkcalendar") => in_mkcalendar = false, + s if s == "mkcalendar" || s.ends_with(":mkcalendar") => { + in_mkcalendar = false + } 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 == "calendar-description" || s.ends_with(":calendar-description") => in_description = false, - s if s == "calendar-color" || s.ends_with(":calendar-color") => in_calendar_color = false, - _ => () + s if s == "displayname" || s.ends_with(":displayname") => { + in_displayname = false + } + s if s == "calendar-description" + || s.ends_with(":calendar-description") => + { + in_description = false + } + s if s == "calendar-color" || s.ends_with(":calendar-color") => { + in_calendar_color = false + } + _ => (), } - }, + } Ok(Event::Eof) => break, Err(e) => return Err(WebDavError::XmlError(e)), _ => (), } - + buffer.clear(); } - + // If no displayname specified, generate a default one based on UUID if displayname.is_empty() { displayname = format!("Calendar {}", Uuid::new_v4()); } - + Ok((displayname, description, color)) } -} \ No newline at end of file +} diff --git a/src/application/adapters/caldav_adapter_test.rs b/src/application/adapters/caldav_adapter_test.rs index d365dfac..59e1c62d 100644 --- a/src/application/adapters/caldav_adapter_test.rs +++ b/src/application/adapters/caldav_adapter_test.rs @@ -1,286 +1,339 @@ -#[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"); - } -} +#[cfg(test)] +mod tests { + 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}; + use chrono::{TimeZone, Utc}; + use std::collections::HashMap; + use std::io::Cursor; + + 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 index 87b1fb24..544999c2 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -1,624 +1,710 @@ -/** - * 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(last), Some(first)) = (&contact.last_name, &contact.first_name) { - vcard.push_str(&format!("N:{};{};;;\r\n", last, first)); - } else if let Some(last) = &contact.last_name { - vcard.push_str(&format!("N:{};;;;\r\n", last)); - } else if let Some(first) = &contact.first_name { - vcard.push_str(&format!("N:;{};;;\r\n", first)); - } - - if let Some(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(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(org) = &contact.organization { - vcard.push_str(&format!("ORG:{}\r\n", org)); - } - if let Some(title) = &contact.title { - vcard.push_str(&format!("TITLE:{}\r\n", title)); - } - if let Some(notes) = &contact.notes { - vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n"))); - } - if let Some(bday) = &contact.birthday { - vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d"))); - } - if let Some(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 -} +use quick_xml::{ + Reader, Writer, + events::{BytesEnd, BytesStart, BytesText, Event}, +}; +/** + * 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::{BufReader, Read, Write}; + +use crate::application::adapters::webdav_adapter::{ + PropFindRequest, PropFindType, QualifiedName, Result, WebDavAdapter, 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(last), Some(first)) = (&contact.last_name, &contact.first_name) { + vcard.push_str(&format!("N:{};{};;;\r\n", last, first)); + } else if let Some(last) = &contact.last_name { + vcard.push_str(&format!("N:{};;;;\r\n", last)); + } else if let Some(first) = &contact.first_name { + vcard.push_str(&format!("N:;{};;;\r\n", first)); + } + + if let Some(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(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(org) = &contact.organization { + vcard.push_str(&format!("ORG:{}\r\n", org)); + } + if let Some(title) = &contact.title { + vcard.push_str(&format!("TITLE:{}\r\n", title)); + } + if let Some(notes) = &contact.notes { + vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n"))); + } + if let Some(bday) = &contact.birthday { + vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d"))); + } + if let Some(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 index b7c9bf52..bc51e080 100644 --- a/src/application/adapters/carddav_adapter_test.rs +++ b/src/application/adapters/carddav_adapter_test.rs @@ -1,432 +1,524 @@ -#[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"); - } -} +#[cfg(test)] +mod tests { + 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::{AddressDto, ContactDto, EmailDto, PhoneDto}; + use chrono::{NaiveDate, TimeZone, Utc}; + use std::io::Cursor; + + 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 cccc913b..6fd285f1 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -1,8 +1,8 @@ //! Adapters module for translating between external protocols and internal models -pub mod webdav_adapter; pub mod caldav_adapter; pub mod carddav_adapter; +pub mod webdav_adapter; #[cfg(test)] mod caldav_adapter_test; diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 469c5232..99d0e288 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -1,15 +1,17 @@ +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use chrono::Utc; +use quick_xml::{ + Reader, Writer, + events::{BytesEnd, BytesStart, BytesText, Event}, +}; /** * WebDAV Adapter Module - * + * * This module provides conversion between WebDAV protocol XML structures and OxiCloud domain objects. * It handles parsing WebDAV request XML and generating WebDAV response XML according to RFC 4918. */ - -use std::io::{Read, Write, BufReader}; -use quick_xml::{Reader, Writer, events::{Event, BytesStart, BytesEnd, BytesText}}; -use chrono::Utc; -use crate::application::dtos::file_dto::FileDto; -use crate::application::dtos::folder_dto::FolderDto; +use std::io::{BufReader, Read, Write}; /// Result type for WebDAV operations pub type Result = std::result::Result; @@ -58,7 +60,7 @@ impl QualifiedName { name: name.into(), } } - + pub fn to_string(&self) -> String { if self.namespace.is_empty() { self.name.clone() @@ -124,40 +126,44 @@ impl WebDavAdapter { pub fn parse_propfind(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_propfind = false; let mut in_prop = false; let mut in_allprop = false; let mut in_propname = false; let mut props = Vec::new(); - + 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(""); - + if name_str == "propfind" || name_str.ends_with(":propfind") { in_propfind = true; } else if in_propfind && (name_str == "prop" || name_str.ends_with(":prop")) { in_prop = true; - } else if in_propfind && (name_str == "allprop" || name_str.ends_with(":allprop")) { + } else if in_propfind + && (name_str == "allprop" || name_str.ends_with(":allprop")) + { in_allprop = true; - } else if in_propfind && (name_str == "propname" || name_str.ends_with(":propname")) { + } else if in_propfind + && (name_str == "propname" || name_str.ends_with(":propname")) + { in_propname = true; } else if in_prop { // Add property to request let namespace = Self::extract_namespace(name_str); let prop_name = Self::extract_local_name(name_str); - + props.push(QualifiedName::new(namespace, prop_name)); } - }, + } Ok(Event::End(ref e)) => { let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); - + if name_str == "propfind" || name_str.ends_with(":propfind") { in_propfind = false; } else if name_str == "prop" || name_str.ends_with(":prop") { @@ -167,31 +173,33 @@ impl WebDavAdapter { } else if name_str == "propname" || name_str.ends_with(":propname") { in_propname = false; } - }, + } Ok(Event::Empty(ref e)) => { let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); - + if in_propfind && (name_str == "allprop" || name_str.ends_with(":allprop")) { in_allprop = true; - } else if in_propfind && (name_str == "propname" || name_str.ends_with(":propname")) { + } else if in_propfind + && (name_str == "propname" || name_str.ends_with(":propname")) + { in_propname = true; } else if in_prop { // Add property to request (empty element) let namespace = Self::extract_namespace(name_str); let prop_name = Self::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(); } - + let prop_find_type = if in_allprop { PropFindType::AllProp } else if in_propname { @@ -199,10 +207,10 @@ impl WebDavAdapter { } else { PropFindType::Prop(props) }; - + Ok(PropFindRequest { prop_find_type }) } - + /// Generate a PROPFIND response for files and folders pub fn generate_propfind_response( writer: W, @@ -214,36 +222,46 @@ impl WebDavAdapter { base_href: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); - + // Start multistatus response - xml_writer.write_event(Event::Start(BytesStart::new("D:multistatus").with_attributes([ - ("xmlns:D", "DAV:"), - ])))?; - + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]), + ))?; + // Add response for current folder if provided if let Some(folder) = folder { Self::write_folder_response(&mut xml_writer, folder, request, base_href)?; } - + // If depth allows, add responses for files and subfolders if _depth != "0" { // Add responses for files for file in files { - Self::write_file_response(&mut xml_writer, file, request, &format!("{}{}", base_href, file.name))?; + Self::write_file_response( + &mut xml_writer, + file, + request, + &format!("{}{}", base_href, file.name), + )?; } - + // Add responses for subfolders for subfolder in subfolders { - Self::write_folder_response(&mut xml_writer, subfolder, request, &format!("{}{}/", base_href, subfolder.name))?; + Self::write_folder_response( + &mut xml_writer, + subfolder, + request, + &format!("{}{}/", base_href, subfolder.name), + )?; } } - + // End multistatus xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - + Ok(()) } - + /// Generate a PROPFIND response for a single file pub fn generate_propfind_response_for_file( writer: W, @@ -253,21 +271,21 @@ impl WebDavAdapter { href: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); - + // Start multistatus response - xml_writer.write_event(Event::Start(BytesStart::new("D:multistatus").with_attributes([ - ("xmlns:D", "DAV:"), - ])))?; - + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]), + ))?; + // Add response for file Self::write_file_response(&mut xml_writer, file, request, href)?; - + // End multistatus xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - + Ok(()) } - + /// Write folder properties as a response fn write_folder_response( xml_writer: &mut Writer, @@ -277,51 +295,51 @@ impl WebDavAdapter { ) -> Result<()> { // Start response element xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; - + // Write href 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")))?; - + // Write propstat xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - + // Start prop xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - + // Write properties based on request type match &request.prop_find_type { PropFindType::AllProp => { // Write all standard properties for a folder Self::write_folder_standard_props(xml_writer, folder)?; - }, + } PropFindType::PropName => { // Write only property names (empty elements) Self::write_folder_prop_names(xml_writer)?; - }, + } PropFindType::Prop(props) => { // Write requested properties Self::write_folder_requested_props(xml_writer, folder, props)?; } } - + // End prop xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + // Write status 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")))?; - + // End propstat xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; - + // End response xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; - + Ok(()) } - + /// Write file properties as a response fn write_file_response( xml_writer: &mut Writer, @@ -331,51 +349,51 @@ impl WebDavAdapter { ) -> Result<()> { // Start response element xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; - + // Write href 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")))?; - + // Write propstat xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - + // Start prop xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - + // Write properties based on request type match &request.prop_find_type { PropFindType::AllProp => { // Write all standard properties for a file Self::write_file_standard_props(xml_writer, file)?; - }, + } PropFindType::PropName => { // Write only property names (empty elements) Self::write_file_prop_names(xml_writer)?; - }, + } PropFindType::Prop(props) => { // Write requested properties Self::write_file_requested_props(xml_writer, file, props)?; } } - + // End prop xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + // Write status 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")))?; - + // End propstat xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; - + // End response xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; - + Ok(()) } - + /// Write standard folder properties fn write_folder_standard_props( xml_writer: &mut Writer, @@ -385,50 +403,50 @@ impl WebDavAdapter { 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::End(BytesEnd::new("D:resourcetype")))?; - + // Display name xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; xml_writer.write_event(Event::Text(BytesText::new(&folder.name)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; - + // Creation date xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - + // Convert u64 timestamp to DateTime let created_at = chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) .unwrap_or_else(Utc::now); - + xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; - + // Last modified xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - + // Convert u64 timestamp to DateTime let modified_at = chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) .unwrap_or_else(Utc::now); - + xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - + // Other standard properties xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.id))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; - + // Content length (0 for directories) xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; xml_writer.write_event(Event::Text(BytesText::new("0")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; - + // Content type for directories xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - + Ok(()) } - + /// Write standard file properties fn write_file_standard_props( xml_writer: &mut Writer, @@ -436,54 +454,52 @@ impl WebDavAdapter { ) -> Result<()> { // Resource type (empty for files) xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; - + // Display name xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; xml_writer.write_event(Event::Text(BytesText::new(&file.name)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; - + // Content type xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; xml_writer.write_event(Event::Text(BytesText::new(&file.mime_type)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - + // Content length xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; xml_writer.write_event(Event::Text(BytesText::new(&file.size.to_string())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; - + // Creation date xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - + // Convert u64 timestamp to DateTime let created_at = chrono::DateTime::::from_timestamp(file.created_at as i64, 0) .unwrap_or_else(Utc::now); - + xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; - + // Last modified xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - + // Convert u64 timestamp to DateTime let modified_at = chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) .unwrap_or_else(Utc::now); - + xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - + // ETag xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.id))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; - + Ok(()) } - + /// Write folder property names - fn write_folder_prop_names( - xml_writer: &mut Writer, - ) -> Result<()> { + fn write_folder_prop_names(xml_writer: &mut Writer) -> Result<()> { // Write empty property elements for folders xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?; @@ -492,14 +508,12 @@ impl WebDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:getetag")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontentlength")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontenttype")))?; - + Ok(()) } - + /// Write file property names - fn write_file_prop_names( - xml_writer: &mut Writer, - ) -> Result<()> { + fn write_file_prop_names(xml_writer: &mut Writer) -> Result<()> { // Write empty property elements for files xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?; @@ -508,10 +522,10 @@ impl WebDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:creationdate")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:getlastmodified")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:getetag")))?; - + Ok(()) } - + /// Write requested folder properties fn write_folder_requested_props( xml_writer: &mut Writer, @@ -525,61 +539,78 @@ impl WebDavAdapter { 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::End(BytesEnd::new("D:resourcetype")))?; - }, + } "displayname" => { xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; xml_writer.write_event(Event::Text(BytesText::new(&folder.name)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; - }, + } "creationdate" => { xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - + // Convert u64 timestamp to DateTime - let created_at = chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; + let created_at = + chrono::DateTime::::from_timestamp(folder.created_at as i64, 0) + .unwrap_or_else(Utc::now); + + xml_writer + .write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; - }, + } "getlastmodified" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - + xml_writer + .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + // Convert u64 timestamp to DateTime - let modified_at = chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; + let modified_at = + chrono::DateTime::::from_timestamp(folder.modified_at as i64, 0) + .unwrap_or_else(Utc::now); + + xml_writer + .write_event(Event::Text(BytesText::new(&modified_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!("\"{}\"", folder.id))))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + folder.id + ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; - }, + } "getcontentlength" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; xml_writer.write_event(Event::Text(BytesText::new("0")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; - }, + } "getcontenttype" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; - xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?; + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer + .write_event(Event::Text(BytesText::new("httpd/unix-directory")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - }, + } _ => { // Property not supported - write empty element - xml_writer.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?; + xml_writer.write_event(Event::Empty(BytesStart::new(format!( + "D:{}", + prop.name + ))))?; } } } else { // Non-DAV namespace, not supported - xml_writer.write_event(Event::Empty(BytesStart::new(format!("{}:{}", prop.namespace, prop.name))))?; + xml_writer.write_event(Event::Empty(BytesStart::new(format!( + "{}:{}", + prop.namespace, prop.name + ))))?; } } - + Ok(()) } - + /// Write requested file properties fn write_file_requested_props( xml_writer: &mut Writer, @@ -591,66 +622,83 @@ impl WebDavAdapter { match prop.name.as_str() { "resourcetype" => { xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; - }, + } "displayname" => { xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; xml_writer.write_event(Event::Text(BytesText::new(&file.name)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; - }, + } "getcontenttype" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; xml_writer.write_event(Event::Text(BytesText::new(&file.mime_type)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; - }, + } "getcontentlength" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; - xml_writer.write_event(Event::Text(BytesText::new(&file.size.to_string())))?; + xml_writer + .write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + xml_writer + .write_event(Event::Text(BytesText::new(&file.size.to_string())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; - }, + } "creationdate" => { xml_writer.write_event(Event::Start(BytesStart::new("D:creationdate")))?; - + // Convert u64 timestamp to DateTime - let created_at = chrono::DateTime::::from_timestamp(file.created_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; + let created_at = + chrono::DateTime::::from_timestamp(file.created_at as i64, 0) + .unwrap_or_else(Utc::now); + + xml_writer + .write_event(Event::Text(BytesText::new(&created_at.to_rfc3339())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:creationdate")))?; - }, + } "getlastmodified" => { - xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; - + xml_writer + .write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + // Convert u64 timestamp to DateTime - let modified_at = chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) - .unwrap_or_else(Utc::now); - - xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; + let modified_at = + chrono::DateTime::::from_timestamp(file.modified_at as i64, 0) + .unwrap_or_else(Utc::now); + + xml_writer + .write_event(Event::Text(BytesText::new(&modified_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!("\"{}\"", file.id))))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "\"{}\"", + file.id + ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; - }, + } _ => { // Property not supported - write empty element - xml_writer.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?; + xml_writer.write_event(Event::Empty(BytesStart::new(format!( + "D:{}", + prop.name + ))))?; } } } else { // Non-DAV namespace, not supported - xml_writer.write_event(Event::Empty(BytesStart::new(format!("{}:{}", prop.namespace, prop.name))))?; + xml_writer.write_event(Event::Empty(BytesStart::new(format!( + "{}:{}", + prop.namespace, prop.name + ))))?; } } - + Ok(()) } - + /// Parse a PROPPATCH XML request pub fn parse_proppatch(reader: R) -> Result<(Vec, Vec)> { 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_propertyupdate = false; let mut in_set = false; @@ -660,40 +708,50 @@ impl WebDavAdapter { let mut props_to_set = Vec::new(); let mut props_to_remove = Vec::new(); let mut current_text = String::new(); - + 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 == "propertyupdate" || s.ends_with(":propertyupdate") => in_propertyupdate = true, - s if (in_propertyupdate && (s == "set" || s.ends_with(":set"))) => in_set = true, - s if (in_propertyupdate && (s == "remove" || s.ends_with(":remove"))) => in_remove = true, - s if ((in_set || in_remove) && (s == "prop" || s.ends_with(":prop"))) => in_prop = true, + s if s == "propertyupdate" || s.ends_with(":propertyupdate") => { + in_propertyupdate = true + } + s if (in_propertyupdate && (s == "set" || s.ends_with(":set"))) => { + in_set = true + } + s if (in_propertyupdate && (s == "remove" || s.ends_with(":remove"))) => { + in_remove = true + } + s if ((in_set || in_remove) && (s == "prop" || s.ends_with(":prop"))) => { + in_prop = true + } _ if in_prop => { // This is a property element let namespace = Self::extract_namespace(name_str); let prop_name = Self::extract_local_name(name_str); - + current_prop = Some(QualifiedName::new(namespace, prop_name)); current_text.clear(); } - _ => () + _ => (), } - }, + } Ok(Event::Text(e)) => { if current_prop.is_some() { current_text.push_str(&e.decode().unwrap_or_default()); } - }, + } 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 == "propertyupdate" || s.ends_with(":propertyupdate") => in_propertyupdate = false, + s if s == "propertyupdate" || s.ends_with(":propertyupdate") => { + in_propertyupdate = false + } s if s == "set" || s.ends_with(":set") => in_set = false, s if s == "remove" || s.ends_with(":remove") => in_remove = false, s if s == "prop" || s.ends_with(":prop") => in_prop = false, @@ -703,7 +761,11 @@ impl WebDavAdapter { if in_set { props_to_set.push(PropValue { name: prop_name, - value: if current_text.is_empty() { None } else { Some(current_text.clone()) }, + value: if current_text.is_empty() { + None + } else { + Some(current_text.clone()) + }, }); } else if in_remove { props_to_remove.push(prop_name); @@ -711,20 +773,20 @@ impl WebDavAdapter { } current_text.clear(); } - _ => () + _ => (), } - }, + } Ok(Event::Empty(ref e)) => { let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); - + if in_prop { // Empty property element let namespace = Self::extract_namespace(name_str); let prop_name = Self::extract_local_name(name_str); - + let qname = QualifiedName::new(namespace, prop_name); - + if in_set { props_to_set.push(PropValue { name: qname, @@ -734,18 +796,18 @@ impl WebDavAdapter { props_to_remove.push(qname); } } - }, + } Ok(Event::Eof) => break, Err(e) => return Err(WebDavError::XmlError(e)), _ => (), } - + buffer.clear(); } - + Ok((props_to_set, props_to_remove)) } - + /// Generate a PROPPATCH response pub fn generate_proppatch_response( writer: W, @@ -753,24 +815,24 @@ impl WebDavAdapter { results: &[(&QualifiedName, bool)], ) -> Result<()> { let mut xml_writer = Writer::new(writer); - + // Start multistatus response - xml_writer.write_event(Event::Start(BytesStart::new("D:multistatus").with_attributes([ - ("xmlns:D", "DAV:"), - ])))?; - + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]), + ))?; + // Start response element xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; - + // Write href 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")))?; - + // Group results by status let mut success_props = Vec::new(); let mut failed_props = Vec::new(); - + for (prop, success) in results { if *success { success_props.push(prop); @@ -778,14 +840,14 @@ impl WebDavAdapter { failed_props.push(prop); } } - + // Write successful properties if !success_props.is_empty() { xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - + // Start prop xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - + // Write property names for prop in success_props { let prop_name = if prop.namespace == "DAV:" { @@ -795,26 +857,26 @@ impl WebDavAdapter { }; xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; } - + // End prop xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + // Write status 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")))?; - + // End propstat xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; } - + // Write failed properties if !failed_props.is_empty() { xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; - + // Start prop xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; - + // Write property names for prop in failed_props { let prop_name = if prop.namespace == "DAV:" { @@ -824,33 +886,33 @@ impl WebDavAdapter { }; xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; } - + // End prop xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + // Write status xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 403 Forbidden")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; - + // End propstat xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; } - + // End response xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; - + // End multistatus xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - + Ok(()) } - + /// Parse a LOCK XML request pub fn parse_lockinfo(reader: R) -> Result<(LockScope, LockType, 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_lockinfo = false; let mut in_lockscope = false; @@ -858,66 +920,88 @@ impl WebDavAdapter { let mut in_owner = false; let mut owner_text = String::new(); let mut scope = LockScope::Exclusive; // Default to exclusive - let mut type_ = LockType::Write; // Default to write (only supported type) - + let mut type_ = LockType::Write; // Default to write (only supported type) + 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 == "lockinfo" || s.ends_with(":lockinfo") => in_lockinfo = true, - s if in_lockinfo && (s == "lockscope" || s.ends_with(":lockscope")) => in_lockscope = true, - s if in_lockinfo && (s == "locktype" || s.ends_with(":locktype")) => in_locktype = true, - s if in_lockinfo && (s == "owner" || s.ends_with(":owner")) => in_owner = true, - s if in_lockscope && (s == "exclusive" || s.ends_with(":exclusive")) => scope = LockScope::Exclusive, - s if in_lockscope && (s == "shared" || s.ends_with(":shared")) => scope = LockScope::Shared, - s if in_locktype && (s == "write" || s.ends_with(":write")) => type_ = LockType::Write, - _ => () + s if in_lockinfo && (s == "lockscope" || s.ends_with(":lockscope")) => { + in_lockscope = true + } + s if in_lockinfo && (s == "locktype" || s.ends_with(":locktype")) => { + in_locktype = true + } + s if in_lockinfo && (s == "owner" || s.ends_with(":owner")) => { + in_owner = true + } + s if in_lockscope && (s == "exclusive" || s.ends_with(":exclusive")) => { + scope = LockScope::Exclusive + } + s if in_lockscope && (s == "shared" || s.ends_with(":shared")) => { + scope = LockScope::Shared + } + s if in_locktype && (s == "write" || s.ends_with(":write")) => { + type_ = LockType::Write + } + _ => (), } - }, + } Ok(Event::Text(e)) => { if in_owner { owner_text.push_str(&e.decode().unwrap_or_default()); } - }, + } 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 == "lockinfo" || s.ends_with(":lockinfo") => in_lockinfo = false, s if s == "lockscope" || s.ends_with(":lockscope") => in_lockscope = false, s if s == "locktype" || s.ends_with(":locktype") => in_locktype = false, s if s == "owner" || s.ends_with(":owner") => in_owner = false, - _ => () + _ => (), } - }, + } Ok(Event::Empty(ref e)) => { let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); - + match name_str { - s if in_lockscope && (s == "exclusive" || s.ends_with(":exclusive")) => scope = LockScope::Exclusive, - s if in_lockscope && (s == "shared" || s.ends_with(":shared")) => scope = LockScope::Shared, - s if in_locktype && (s == "write" || s.ends_with(":write")) => type_ = LockType::Write, - _ => () + s if in_lockscope && (s == "exclusive" || s.ends_with(":exclusive")) => { + scope = LockScope::Exclusive + } + s if in_lockscope && (s == "shared" || s.ends_with(":shared")) => { + scope = LockScope::Shared + } + s if in_locktype && (s == "write" || s.ends_with(":write")) => { + type_ = LockType::Write + } + _ => (), } - }, + } Ok(Event::Eof) => break, Err(e) => return Err(WebDavError::XmlError(e)), _ => (), } - + buffer.clear(); } - - let owner = if owner_text.is_empty() { None } else { Some(owner_text) }; - + + let owner = if owner_text.is_empty() { + None + } else { + Some(owner_text) + }; + Ok((scope, type_, owner)) } - + /// Generate a LOCK response (lockdiscovery) pub fn generate_lock_response( writer: W, @@ -925,92 +1009,95 @@ impl WebDavAdapter { href: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); - + // Start prop element (direct response, not multistatus) - xml_writer.write_event(Event::Start(BytesStart::new("D:prop").with_attributes([ - ("xmlns:D", "DAV:"), - ])))?; - + xml_writer.write_event(Event::Start( + BytesStart::new("D:prop").with_attributes([("xmlns:D", "DAV:")]), + ))?; + // Start lockdiscovery xml_writer.write_event(Event::Start(BytesStart::new("D:lockdiscovery")))?; - + // Start activelock xml_writer.write_event(Event::Start(BytesStart::new("D:activelock")))?; - + // Write locktype xml_writer.write_event(Event::Start(BytesStart::new("D:locktype")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:locktype")))?; - + // Write lockscope xml_writer.write_event(Event::Start(BytesStart::new("D:lockscope")))?; match lock_info.scope { LockScope::Exclusive => { xml_writer.write_event(Event::Empty(BytesStart::new("D:exclusive")))?; - }, + } LockScope::Shared => { xml_writer.write_event(Event::Empty(BytesStart::new("D:shared")))?; } } xml_writer.write_event(Event::End(BytesEnd::new("D:lockscope")))?; - + // Write depth xml_writer.write_event(Event::Start(BytesStart::new("D:depth")))?; xml_writer.write_event(Event::Text(BytesText::new(&lock_info.depth)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:depth")))?; - + // Write owner (if provided) if let Some(owner) = &lock_info.owner { xml_writer.write_event(Event::Start(BytesStart::new("D:owner")))?; xml_writer.write_event(Event::Text(BytesText::new(owner)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:owner")))?; } - + // Write timeout (if provided) if let Some(timeout) = &lock_info.timeout { xml_writer.write_event(Event::Start(BytesStart::new("D:timeout")))?; xml_writer.write_event(Event::Text(BytesText::new(timeout)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:timeout")))?; } - + // Write locktoken xml_writer.write_event(Event::Start(BytesStart::new("D:locktoken")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; xml_writer.write_event(Event::Text(BytesText::new(&lock_info.token)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:locktoken")))?; - + // Write lockroot xml_writer.write_event(Event::Start(BytesStart::new("D:lockroot")))?; 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::End(BytesEnd::new("D:lockroot")))?; - + // End activelock, lockdiscovery, and prop xml_writer.write_event(Event::End(BytesEnd::new("D:activelock")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:lockdiscovery")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; - + Ok(()) } - + /// Helper method to extract namespace from tag name pub fn extract_namespace(name: &str) -> String { if let Some(idx) = name.rfind(':') - && idx > 0 { - return name[..idx].to_string(); - } + && idx > 0 + { + return name[..idx].to_string(); + } // Default namespace for WebDAV "DAV:".to_string() } - + /// Helper method to extract local name from tag name pub fn extract_local_name(name: &str) -> String { if let Some(idx) = name.rfind(':') - && idx > 0 && idx < name.len() - 1 { - return name[idx+1..].to_string(); - } + && idx > 0 + && idx < name.len() - 1 + { + return name[idx + 1..].to_string(); + } name.to_string() } -} \ No newline at end of file +} diff --git a/src/application/dtos/address_book_dto.rs b/src/application/dtos/address_book_dto.rs index 67a0f233..7523f655 100644 --- a/src/application/dtos/address_book_dto.rs +++ b/src/application/dtos/address_book_dto.rs @@ -1,6 +1,6 @@ +use crate::domain::entities::contact::AddressBook; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::domain::entities::contact::AddressBook; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AddressBookDto { @@ -73,4 +73,4 @@ pub struct ShareAddressBookDto { pub struct UnshareAddressBookDto { pub address_book_id: String, pub user_id: String, -} \ No newline at end of file +} diff --git a/src/application/dtos/calendar_dto.rs b/src/application/dtos/calendar_dto.rs index 314e1e48..92fca841 100644 --- a/src/application/dtos/calendar_dto.rs +++ b/src/application/dtos/calendar_dto.rs @@ -1,8 +1,8 @@ -use serde::{Serialize, Deserialize}; -use chrono::{DateTime, Utc}; -use std::collections::HashMap; use crate::domain::entities::calendar::Calendar; use crate::domain::entities::calendar_event::CalendarEvent; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// DTO for calendar data transfer #[derive(Debug, Serialize, Deserialize, Clone)] @@ -178,4 +178,4 @@ pub struct EventQueryDto { pub struct PaginationDto { pub limit: Option, pub offset: Option, -} \ No newline at end of file +} diff --git a/src/application/dtos/contact_dto.rs b/src/application/dtos/contact_dto.rs index aed4ec3d..bd988afd 100644 --- a/src/application/dtos/contact_dto.rs +++ b/src/application/dtos/contact_dto.rs @@ -1,6 +1,6 @@ +use crate::domain::entities::contact::{Address, Contact, ContactGroup, Email, Phone}; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; -use crate::domain::entities::contact::{Contact, Email, Phone, Address, ContactGroup}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmailDto { @@ -221,4 +221,4 @@ pub struct UpdateContactGroupDto { pub struct GroupMembershipDto { pub group_id: String, pub contact_id: String, -} \ No newline at end of file +} diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 04c6771d..56a9e0ba 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -1,21 +1,21 @@ -use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; /// DTO for favorites item #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FavoriteItemDto { /// Unique identifier for the favorite entry pub id: String, - + /// User ID who owns this favorite pub user_id: String, - + /// ID of the favorited item (file or folder) pub item_id: String, - + /// Type of the item ('file' or 'folder') pub item_type: String, - + /// When the item was added to favorites pub created_at: DateTime, -} \ No newline at end of file +} diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 7f8b4d14..d6875038 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -1,30 +1,30 @@ -use serde::{Serialize, Deserialize}; use crate::domain::entities::file::File; +use serde::{Deserialize, Serialize}; /// DTO for file responses #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileDto { /// File ID pub id: String, - + /// File name pub name: String, - + /// Path to the file (relative) pub path: String, - + /// Size in bytes pub size: u64, - + /// MIME type pub mime_type: String, - + /// Parent folder ID pub folder_id: Option, - + /// Creation timestamp pub created_at: u64, - + /// Last modification timestamp pub modified_at: u64, } @@ -51,14 +51,14 @@ impl From for File { // Note: this should be simplified if File has a proper constructor // If not, make the conversion as best as possible File::from_dto( - dto.id, - dto.name, + dto.id, + dto.name, dto.path, dto.size, dto.mime_type, dto.folder_id, dto.created_at, - dto.modified_at + dto.modified_at, ) } } @@ -83,4 +83,4 @@ impl Default for FileDto { fn default() -> Self { Self::empty() } -} \ No newline at end of file +} diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index b6e6cf6b..2f6d8616 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -1,12 +1,12 @@ -use serde::{Serialize, Deserialize}; use crate::domain::entities::folder::Folder; +use serde::{Deserialize, Serialize}; /// DTO for folder creation requests #[derive(Debug, Deserialize)] pub struct CreateFolderDto { /// Name of the folder to create pub name: String, - + /// Parent folder ID (None for root level) pub parent_id: Option, } @@ -30,22 +30,22 @@ pub struct MoveFolderDto { pub struct FolderDto { /// Folder ID pub id: String, - + /// Folder name pub name: String, - + /// Path to the folder (relative) pub path: String, - + /// Parent folder ID pub parent_id: Option, - + /// Creation timestamp pub created_at: u64, - + /// Last modification timestamp pub modified_at: u64, - + /// Whether this is a root folder pub is_root: bool, } @@ -53,7 +53,7 @@ pub struct FolderDto { impl From for FolderDto { fn from(folder: Folder) -> Self { let is_root = folder.parent_id().is_none(); - + Self { id: folder.id().to_string(), name: folder.name().to_string(), @@ -77,7 +77,7 @@ impl From for Folder { dto.path, dto.parent_id, dto.created_at, - dto.modified_at + dto.modified_at, ) } } @@ -101,4 +101,4 @@ impl Default for FolderDto { fn default() -> Self { Self::empty() } -} \ No newline at end of file +} diff --git a/src/application/dtos/i18n_dto.rs b/src/application/dtos/i18n_dto.rs index bd3dea72..4830a45b 100644 --- a/src/application/dtos/i18n_dto.rs +++ b/src/application/dtos/i18n_dto.rs @@ -1,12 +1,12 @@ -use serde::{Serialize, Deserialize}; use crate::domain::services::i18n_service::Locale; +use serde::{Deserialize, Serialize}; /// DTO for locale information #[derive(Debug, Serialize, Deserialize)] pub struct LocaleDto { /// Locale code (e.g., "en", "es") pub code: String, - + /// Locale name in its own language (e.g., "English", "Español") pub name: String, } @@ -20,7 +20,7 @@ impl From for LocaleDto { Locale::German => ("de", "Deutsch"), Locale::Portuguese => ("pt", "Português"), }; - + Self { code: code.to_string(), name: name.to_string(), @@ -33,7 +33,7 @@ impl From for LocaleDto { pub struct TranslationRequestDto { /// The translation key pub key: String, - + /// The locale code (optional, defaults to "en") pub locale: Option, } @@ -43,10 +43,10 @@ pub struct TranslationRequestDto { pub struct TranslationResponseDto { /// The translation key pub key: String, - + /// The locale code used for translation pub locale: String, - + /// The translated text pub text: String, } @@ -56,10 +56,10 @@ pub struct TranslationResponseDto { pub struct TranslationErrorDto { /// The translation key that was not found pub key: String, - + /// The locale code used for translation pub locale: String, - + /// The error message pub error: String, -} \ No newline at end of file +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 33f26476..175331d7 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -12,4 +12,3 @@ pub mod settings_dto; pub mod share_dto; pub mod trash_dto; pub mod user_dto; - diff --git a/src/application/dtos/pagination.rs b/src/application/dtos/pagination.rs index b8882451..f32ab389 100644 --- a/src/application/dtos/pagination.rs +++ b/src/application/dtos/pagination.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// A DTO to represent pagination information #[derive(Debug, Clone, Serialize, Deserialize)] @@ -56,50 +56,42 @@ impl PaginationRequestDto { pub fn offset(&self) -> usize { self.page * self.page_size } - + /// Calculates the limit for paginated queries pub fn limit(&self) -> usize { self.page_size } - + /// Validates and adjusts the pagination parameters pub fn validate_and_adjust(&self) -> Self { let mut page = self.page; let mut page_size = self.page_size; - + // Ensure the page is at least 0 if page < 1 { page = 0; } - + // Ensure the page size is between 10 and 500 if page_size < 10 { page_size = 10; } else if page_size > 500 { page_size = 500; } - - Self { - page, - page_size, - } + + Self { page, page_size } } } impl PaginatedResponseDto { /// Creates a new paginated response from the data and pagination information - pub fn new( - items: Vec, - page: usize, - page_size: usize, - total_items: usize, - ) -> Self { + pub fn new(items: Vec, page: usize, page_size: usize, total_items: usize) -> Self { let total_pages = if total_items == 0 { 0 } else { total_items.div_ceil(page_size) }; - + let pagination = PaginationDto { page, page_size, @@ -108,10 +100,7 @@ impl PaginatedResponseDto { has_next: page < total_pages - 1, has_prev: page > 0, }; - - Self { - items, - pagination, - } + + Self { items, pagination } } -} \ No newline at end of file +} diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 75a7c4e9..4e51a81e 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,21 +1,21 @@ -use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; /// DTO for recent items #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecentItemDto { /// Unique identifier for the recent item pub id: String, - + /// Owner user ID pub user_id: String, - + /// Item ID (file or folder) pub item_id: String, - + /// Item type ('file' or 'folder') pub item_type: String, - + /// When the item was accessed pub accessed_at: DateTime, -} \ No newline at end of file +} diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index a23b464c..c868a31f 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -1,9 +1,9 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /** * Data Transfer Object for file search criteria. - * - * This structure represents all possible search parameters that can be used + * + * This structure represents all possible search parameters that can be used * to filter files and folders in the system. It supports various filter types * including name matching, file types, date ranges, and size constraints. */ @@ -12,47 +12,47 @@ pub struct SearchCriteriaDto { /// Optional text to search in file/folder names #[serde(skip_serializing_if = "Option::is_none")] pub name_contains: Option, - + /// Optional list of file extensions to include (e.g., "pdf", "jpg") #[serde(skip_serializing_if = "Option::is_none")] pub file_types: Option>, - + /// Optional minimum creation date (seconds since epoch) #[serde(skip_serializing_if = "Option::is_none")] pub created_after: Option, - + /// Optional maximum creation date (seconds since epoch) #[serde(skip_serializing_if = "Option::is_none")] pub created_before: Option, - + /// Optional minimum modification date (seconds since epoch) #[serde(skip_serializing_if = "Option::is_none")] pub modified_after: Option, - + /// Optional maximum modification date (seconds since epoch) #[serde(skip_serializing_if = "Option::is_none")] pub modified_before: Option, - + /// Optional minimum file size in bytes #[serde(skip_serializing_if = "Option::is_none")] pub min_size: Option, - + /// Optional maximum file size in bytes #[serde(skip_serializing_if = "Option::is_none")] pub max_size: Option, - + /// Optional folder ID to limit search scope #[serde(skip_serializing_if = "Option::is_none")] pub folder_id: Option, - + /// Whether to search recursively within subfolders (default: true) #[serde(default = "default_recursive")] pub recursive: bool, - + /// Maximum number of results to return #[serde(default = "default_limit")] pub limit: usize, - + /// Offset for pagination #[serde(default)] pub offset: usize, @@ -89,7 +89,7 @@ impl Default for SearchCriteriaDto { /** * Data Transfer Object for search results. - * + * * This structure encapsulates the results of a search operation, including * both files and folders that match the search criteria, along with pagination information. */ @@ -97,19 +97,19 @@ impl Default for SearchCriteriaDto { pub struct SearchResultsDto { /// Files matching the search criteria pub files: Vec, - + /// Folders matching the search criteria pub folders: Vec, - + /// Total count of matching items (for pagination) pub total_count: Option, - + /// Limit used in the search pub limit: usize, - + /// Offset used in the search pub offset: usize, - + /// Whether there are more results available pub has_more: bool, } @@ -126,7 +126,7 @@ impl SearchResultsDto { has_more: false, } } - + /// Creates a new search results object from files and folders pub fn new( files: Vec, @@ -139,7 +139,7 @@ impl SearchResultsDto { Some(total) => (offset + files.len() + folders.len()) < total, None => false, }; - + Self { files, folders, @@ -149,4 +149,4 @@ impl SearchResultsDto { has_more, } } -} \ No newline at end of file +} diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 5a3fb42f..4aaf4a4e 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -1,130 +1,130 @@ -use serde::{Serialize, Deserialize}; - -// ============================================================================ -// OIDC Settings DTOs (Admin Panel) -// ============================================================================ - -/// Current OIDC settings returned to admin UI (secrets masked) -#[derive(Debug, Serialize, Deserialize)] -pub struct OidcSettingsDto { - pub enabled: bool, - pub issuer_url: String, - pub client_id: String, - /// True if a client secret is configured (never reveals the actual value) - pub client_secret_set: bool, - pub scopes: String, - pub auto_provision: bool, - pub admin_groups: String, - pub disable_password_login: bool, - pub provider_name: String, - /// Auto-generated callback URL the admin must register in their IdP - pub callback_url: String, - /// Field names overridden by environment variables (read-only in UI) - pub env_overrides: Vec, -} - -/// Request body for saving OIDC settings from the admin panel -#[derive(Debug, Serialize, Deserialize)] -pub struct SaveOidcSettingsDto { - pub enabled: bool, - pub issuer_url: String, - pub client_id: String, - /// Only update if provided and non-empty (None = keep existing) - pub client_secret: Option, - pub scopes: Option, - pub auto_provision: Option, - pub admin_groups: Option, - pub disable_password_login: Option, - pub provider_name: Option, -} - -/// Request body for testing OIDC discovery -#[derive(Debug, Serialize, Deserialize)] -pub struct TestOidcConnectionDto { - pub issuer_url: String, -} - -/// Result of OIDC connection test -#[derive(Debug, Serialize, Deserialize)] -pub struct OidcTestResultDto { - pub success: bool, - pub message: String, - pub issuer: Option, - pub authorization_endpoint: Option, - pub token_endpoint: Option, - pub userinfo_endpoint: Option, - /// Suggested provider name (derived from issuer hostname) - pub provider_name_suggestion: Option, -} - -// ============================================================================ -// Admin User Management DTOs -// ============================================================================ - -/// Request body for updating a user's role -#[derive(Debug, Serialize, Deserialize)] -pub struct UpdateUserRoleDto { - pub role: String, -} - -/// Request body for updating a user's active status -#[derive(Debug, Serialize, Deserialize)] -pub struct UpdateUserActiveDto { - pub active: bool, -} - -/// Request body for updating a user's storage quota -#[derive(Debug, Serialize, Deserialize)] -pub struct UpdateUserQuotaDto { - /// Quota in bytes. Use 0 for unlimited. - pub quota_bytes: i64, -} - -/// Request body for admin-created users -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AdminCreateUserDto { - pub username: String, - pub password: String, - /// Optional — if omitted, a placeholder email is generated - pub email: Option, - /// "admin" or "user"; defaults to "user" - pub role: Option, - /// Storage quota in bytes; 0 = unlimited. If omitted, uses role default. - pub quota_bytes: Option, - /// Whether the account is active; defaults to true - pub active: Option, -} - -/// Request body for admin password reset -#[derive(Debug, Serialize, Deserialize)] -pub struct AdminResetPasswordDto { - pub new_password: String, -} - -/// Query parameters for listing users -#[derive(Debug, Serialize, Deserialize)] -pub struct ListUsersQueryDto { - pub limit: Option, - pub offset: Option, -} - -/// Dashboard statistics -#[derive(Debug, Serialize, Deserialize)] -pub struct DashboardStatsDto { - // System info - pub server_version: String, - pub auth_enabled: bool, - pub oidc_configured: bool, - pub quotas_enabled: bool, - // User stats - pub total_users: i64, - pub active_users: i64, - pub admin_users: i64, - // Storage stats - pub total_quota_bytes: i64, - pub total_used_bytes: i64, - pub storage_usage_percent: f64, - pub users_over_80_percent: i64, - pub users_over_quota: i64, - pub registration_enabled: bool, -} +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// OIDC Settings DTOs (Admin Panel) +// ============================================================================ + +/// Current OIDC settings returned to admin UI (secrets masked) +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcSettingsDto { + pub enabled: bool, + pub issuer_url: String, + pub client_id: String, + /// True if a client secret is configured (never reveals the actual value) + pub client_secret_set: bool, + pub scopes: String, + pub auto_provision: bool, + pub admin_groups: String, + pub disable_password_login: bool, + pub provider_name: String, + /// Auto-generated callback URL the admin must register in their IdP + pub callback_url: String, + /// Field names overridden by environment variables (read-only in UI) + pub env_overrides: Vec, +} + +/// Request body for saving OIDC settings from the admin panel +#[derive(Debug, Serialize, Deserialize)] +pub struct SaveOidcSettingsDto { + pub enabled: bool, + pub issuer_url: String, + pub client_id: String, + /// Only update if provided and non-empty (None = keep existing) + pub client_secret: Option, + pub scopes: Option, + pub auto_provision: Option, + pub admin_groups: Option, + pub disable_password_login: Option, + pub provider_name: Option, +} + +/// Request body for testing OIDC discovery +#[derive(Debug, Serialize, Deserialize)] +pub struct TestOidcConnectionDto { + pub issuer_url: String, +} + +/// Result of OIDC connection test +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcTestResultDto { + pub success: bool, + pub message: String, + pub issuer: Option, + pub authorization_endpoint: Option, + pub token_endpoint: Option, + pub userinfo_endpoint: Option, + /// Suggested provider name (derived from issuer hostname) + pub provider_name_suggestion: Option, +} + +// ============================================================================ +// Admin User Management DTOs +// ============================================================================ + +/// Request body for updating a user's role +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserRoleDto { + pub role: String, +} + +/// Request body for updating a user's active status +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserActiveDto { + pub active: bool, +} + +/// Request body for updating a user's storage quota +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserQuotaDto { + /// Quota in bytes. Use 0 for unlimited. + pub quota_bytes: i64, +} + +/// Request body for admin-created users +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AdminCreateUserDto { + pub username: String, + pub password: String, + /// Optional — if omitted, a placeholder email is generated + pub email: Option, + /// "admin" or "user"; defaults to "user" + pub role: Option, + /// Storage quota in bytes; 0 = unlimited. If omitted, uses role default. + pub quota_bytes: Option, + /// Whether the account is active; defaults to true + pub active: Option, +} + +/// Request body for admin password reset +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminResetPasswordDto { + pub new_password: String, +} + +/// Query parameters for listing users +#[derive(Debug, Serialize, Deserialize)] +pub struct ListUsersQueryDto { + pub limit: Option, + pub offset: Option, +} + +/// Dashboard statistics +#[derive(Debug, Serialize, Deserialize)] +pub struct DashboardStatsDto { + // System info + pub server_version: String, + pub auth_enabled: bool, + pub oidc_configured: bool, + pub quotas_enabled: bool, + // User stats + pub total_users: i64, + pub active_users: i64, + pub admin_users: i64, + // Storage stats + pub total_quota_bytes: i64, + pub total_used_bytes: i64, + pub storage_usage_percent: f64, + pub users_over_80_percent: i64, + pub users_over_quota: i64, + pub registration_enabled: bool, +} diff --git a/src/application/dtos/share_dto.rs b/src/application/dtos/share_dto.rs index 7a0e54af..61b12e73 100644 --- a/src/application/dtos/share_dto.rs +++ b/src/application/dtos/share_dto.rs @@ -44,7 +44,7 @@ pub struct UpdateShareDto { impl ShareDto { pub fn from_entity(share: &Share, base_url: &str) -> Self { let url = format!("{}/s/{}", base_url, share.token()); - + Self { id: share.id().to_string(), item_id: share.item_id().to_string(), @@ -69,7 +69,7 @@ impl SharePermissionsDto { reshare: permissions.reshare(), } } - + pub fn to_entity(&self) -> SharePermissions { SharePermissions::new(self.read, self.write, self.reshare) } diff --git a/src/application/dtos/trash_dto.rs b/src/application/dtos/trash_dto.rs index 4c2a5b62..98760637 100644 --- a/src/application/dtos/trash_dto.rs +++ b/src/application/dtos/trash_dto.rs @@ -30,4 +30,4 @@ pub struct RestoreFromTrashRequest { #[derive(Debug, Deserialize)] pub struct DeletePermanentlyRequest { pub trash_id: String, -} \ No newline at end of file +} diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index c6c4c726..ee6aa295 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -1,6 +1,6 @@ -use serde::{Serialize, Deserialize}; -use chrono::{DateTime, Utc}; use crate::domain::entities::user::User; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct UserDto { @@ -117,4 +117,4 @@ pub struct OidcUserInfoDto { pub email: Option, pub name: Option, pub groups: Vec, -} \ No newline at end of file +} diff --git a/src/application/mod.rs b/src/application/mod.rs index 108da716..86af78c8 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,7 +1,7 @@ +pub mod adapters; pub mod dtos; pub mod ports; pub mod services; pub mod transactions; -pub mod adapters; -// Re-exportaciones para facilitar el acceso a los principales puertos \ No newline at end of file +// Re-exportaciones para facilitar el acceso a los principales puertos diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 3ba14799..ae24fc96 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -1,20 +1,20 @@ -use async_trait::async_trait; -use crate::domain::entities::user::User; -use crate::domain::entities::session::Session; use crate::common::errors::DomainError; +use crate::domain::entities::session::Session; +use crate::domain::entities::user::User; +use async_trait::async_trait; // ============================================================================ // Cryptography Ports - Extracted from Domain to maintain Clean Architecture // ============================================================================ /// Port for password hashing operations. -/// +/// /// This trait abstracts cryptographic password operations, allowing the domain /// layer to remain independent of specific hashing implementations (argon2, bcrypt, etc.) pub trait PasswordHasherPort: Send + Sync + 'static { /// Hash a plain text password fn hash_password(&self, password: &str) -> Result; - + /// Verify a plain text password against a hash fn verify_password(&self, password: &str, hash: &str) -> Result; } @@ -39,22 +39,22 @@ pub struct TokenClaims { } /// Port for JWT token operations. -/// +/// /// This trait abstracts token generation and validation, allowing the domain /// layer to remain independent of specific JWT implementations. pub trait TokenServicePort: Send + Sync + 'static { /// Generate an access token for a user fn generate_access_token(&self, user: &User) -> Result; - + /// Validate a token and extract its claims fn validate_token(&self, token: &str) -> Result; - + /// Generate a refresh token fn generate_refresh_token(&self) -> String; - + /// Get refresh token expiry in seconds fn refresh_token_expiry_secs(&self) -> i64; - + /// Get refresh token expiry in days fn refresh_token_expiry_days(&self) -> i64; } @@ -65,38 +65,46 @@ pub trait TokenServicePort: Send + Sync + 'static { #[async_trait] pub trait UserStoragePort: Send + Sync + 'static { - /// Creates a new user + /// Creates a new user async fn create_user(&self, user: User) -> Result; - + /// Gets a user by ID async fn get_user_by_id(&self, id: &str) -> Result; - + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> Result; - + /// Gets a user by email async fn get_user_by_email(&self, email: &str) -> Result; - + /// Updates an existing user async fn update_user(&self, user: User) -> Result; - + /// Updates only the storage usage of a user - async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> Result<(), DomainError>; - + async fn update_storage_usage( + &self, + user_id: &str, + usage_bytes: i64, + ) -> Result<(), DomainError>; + /// Lists users with pagination async fn list_users(&self, limit: i64, offset: i64) -> Result, DomainError>; - + /// Lists users by role (e.g., "admin" or "user") async fn list_users_by_role(&self, role: &str) -> Result, DomainError>; - + /// Deletes a user by their ID async fn delete_user(&self, user_id: &str) -> Result<(), DomainError>; - + /// Changes a user's password async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError>; /// Finds a user by OIDC provider + subject pair - async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result; + async fn get_user_by_oidc_subject( + &self, + provider: &str, + subject: &str, + ) -> Result; /// Activates or deactivates a user async fn set_user_active_status(&self, user_id: &str, active: bool) -> Result<(), DomainError>; @@ -105,7 +113,11 @@ pub trait UserStoragePort: Send + Sync + 'static { async fn change_role(&self, user_id: &str, role: &str) -> Result<(), DomainError>; /// Updates a user's storage quota - async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError>; + async fn update_storage_quota( + &self, + user_id: &str, + quota_bytes: i64, + ) -> Result<(), DomainError>; /// Counts the total number of users async fn count_users(&self) -> Result; @@ -139,14 +151,27 @@ pub trait OidcServicePort: Send + Sync + 'static { /// Get the authorization URL for redirecting the user to the IdP. /// Includes PKCE code_challenge (S256) and nonce for ID token binding. /// This is async because it may need to fetch the OIDC discovery document. - async fn get_authorize_url(&self, state: &str, nonce: &str, pkce_challenge: &str) -> Result; + async fn get_authorize_url( + &self, + state: &str, + nonce: &str, + pkce_challenge: &str, + ) -> Result; /// Exchange an authorization code for tokens, providing PKCE code_verifier. - async fn exchange_code(&self, code: &str, pkce_verifier: &str) -> Result; + async fn exchange_code( + &self, + code: &str, + pkce_verifier: &str, + ) -> Result; /// Validate an ID token and extract claims. /// If `expected_nonce` is provided, verifies the `nonce` claim matches. - async fn validate_id_token(&self, id_token: &str, expected_nonce: Option<&str>) -> Result; + async fn validate_id_token( + &self, + id_token: &str, + expected_nonce: Option<&str>, + ) -> Result; /// Fetch user info from the UserInfo endpoint (fallback for missing ID token claims) async fn fetch_user_info(&self, access_token: &str) -> Result; @@ -159,13 +184,16 @@ pub trait OidcServicePort: Send + Sync + 'static { pub trait SessionStoragePort: Send + Sync + 'static { /// Creates a new session async fn create_session(&self, session: Session) -> Result; - + /// Gets a session by refresh token - async fn get_session_by_refresh_token(&self, refresh_token: &str) -> Result; - + async fn get_session_by_refresh_token( + &self, + refresh_token: &str, + ) -> Result; + /// Revokes a specific session async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError>; - + /// Revokes all sessions of a user async fn revoke_all_user_sessions(&self, user_id: &str) -> Result; -} \ No newline at end of file +} diff --git a/src/application/ports/cache_ports.rs b/src/application/ports/cache_ports.rs index 3f0edb28..f647a57d 100644 --- a/src/application/ports/cache_ports.rs +++ b/src/application/ports/cache_ports.rs @@ -8,10 +8,10 @@ //! The application and interface layers remain independent of the caching //! implementation details. -use std::path::{Path, PathBuf}; +use crate::common::errors::DomainError; use async_trait::async_trait; use bytes::Bytes; -use crate::common::errors::DomainError; +use std::path::{Path, PathBuf}; /// Statistics for monitoring write-behind cache status. #[derive(Debug, Clone, Default)] diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index 8fdf51ee..46f26bf6 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -1,47 +1,108 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; use crate::application::dtos::calendar_dto::{ - CalendarDto, CalendarEventDto, CreateCalendarDto, UpdateCalendarDto, - CreateEventDto, UpdateEventDto, CreateEventICalDto + CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto, + UpdateCalendarDto, UpdateEventDto, }; use crate::common::errors::DomainError; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; /// Port for external calendar storage mechanisms #[async_trait] pub trait CalendarStoragePort: Send + Sync + 'static { // Calendar operations - async fn create_calendar(&self, calendar: CreateCalendarDto, owner_id: &str) -> Result; - async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result; + async fn create_calendar( + &self, + calendar: CreateCalendarDto, + owner_id: &str, + ) -> Result; + async fn update_calendar( + &self, + calendar_id: &str, + update: UpdateCalendarDto, + ) -> Result; async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>; async fn get_calendar(&self, calendar_id: &str) -> Result; - async fn list_calendars_by_owner(&self, owner_id: &str) -> Result, DomainError>; - async fn list_calendars_shared_with_user(&self, user_id: &str) -> Result, DomainError>; - async fn list_public_calendars(&self, limit: i64, offset: i64) -> Result, DomainError>; - async fn check_calendar_access(&self, calendar_id: &str, user_id: &str) -> Result; - + async fn list_calendars_by_owner( + &self, + owner_id: &str, + ) -> Result, DomainError>; + async fn list_calendars_shared_with_user( + &self, + user_id: &str, + ) -> Result, DomainError>; + async fn list_public_calendars( + &self, + limit: i64, + offset: i64, + ) -> Result, DomainError>; + async fn check_calendar_access( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result; + // Calendar sharing - async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError>; - async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>; - async fn get_calendar_shares(&self, calendar_id: &str) -> Result, DomainError>; - + async fn share_calendar( + &self, + calendar_id: &str, + user_id: &str, + access_level: &str, + ) -> Result<(), DomainError>; + async fn remove_calendar_sharing( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result<(), DomainError>; + async fn get_calendar_shares( + &self, + calendar_id: &str, + ) -> Result, DomainError>; + // Calendar properties - async fn set_calendar_property(&self, calendar_id: &str, property_name: &str, property_value: &str) -> Result<(), DomainError>; - async fn get_calendar_property(&self, calendar_id: &str, property_name: &str) -> Result, DomainError>; - async fn get_calendar_properties(&self, calendar_id: &str) -> Result, DomainError>; - + async fn set_calendar_property( + &self, + calendar_id: &str, + property_name: &str, + property_value: &str, + ) -> Result<(), DomainError>; + async fn get_calendar_property( + &self, + calendar_id: &str, + property_name: &str, + ) -> Result, DomainError>; + async fn get_calendar_properties( + &self, + calendar_id: &str, + ) -> Result, DomainError>; + // Event operations async fn create_event(&self, event: CreateEventDto) -> Result; - async fn create_event_from_ical(&self, event: CreateEventICalDto) -> Result; - async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result; + async fn create_event_from_ical( + &self, + event: CreateEventICalDto, + ) -> Result; + async fn update_event( + &self, + event_id: &str, + update: UpdateEventDto, + ) -> Result; async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>; async fn get_event(&self, event_id: &str) -> Result; - async fn list_events_by_calendar(&self, calendar_id: &str) -> Result, DomainError>; - async fn list_events_by_calendar_paginated(&self, calendar_id: &str, limit: i64, offset: i64) -> Result, DomainError>; + async fn list_events_by_calendar( + &self, + calendar_id: &str, + ) -> Result, DomainError>; + async fn list_events_by_calendar_paginated( + &self, + calendar_id: &str, + limit: i64, + offset: i64, + ) -> Result, DomainError>; async fn get_events_in_time_range( - &self, - calendar_id: &str, - start: &DateTime, - end: &DateTime + &self, + calendar_id: &str, + start: &DateTime, + end: &DateTime, ) -> Result, DomainError>; } @@ -49,41 +110,113 @@ pub trait CalendarStoragePort: Send + Sync + 'static { #[async_trait] pub trait CalendarUseCase: Send + Sync + 'static { // Calendar operations - async fn create_calendar(&self, calendar: CreateCalendarDto) -> Result; - async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result; + async fn create_calendar( + &self, + calendar: CreateCalendarDto, + ) -> Result; + async fn update_calendar( + &self, + calendar_id: &str, + update: UpdateCalendarDto, + ) -> Result; async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>; async fn get_calendar(&self, calendar_id: &str) -> Result; async fn list_my_calendars(&self) -> Result, DomainError>; async fn list_shared_calendars(&self) -> Result, DomainError>; - async fn list_public_calendars(&self, limit: Option, offset: Option) -> Result, DomainError>; - + async fn list_public_calendars( + &self, + limit: Option, + offset: Option, + ) -> Result, DomainError>; + // Calendar sharing - async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError>; - async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>; - async fn get_calendar_shares(&self, calendar_id: &str) -> Result, DomainError>; - + async fn share_calendar( + &self, + calendar_id: &str, + user_id: &str, + access_level: &str, + ) -> Result<(), DomainError>; + async fn remove_calendar_sharing( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result<(), DomainError>; + async fn get_calendar_shares( + &self, + calendar_id: &str, + ) -> Result, DomainError>; + // Event operations async fn create_event(&self, event: CreateEventDto) -> Result; - async fn create_event_from_ical(&self, event: CreateEventICalDto) -> Result; - async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result; + async fn create_event_from_ical( + &self, + event: CreateEventICalDto, + ) -> Result; + async fn update_event( + &self, + event_id: &str, + update: UpdateEventDto, + ) -> Result; async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>; async fn get_event(&self, event_id: &str) -> Result; - async fn list_events(&self, calendar_id: &str, limit: Option, offset: Option) -> Result, DomainError>; - async fn get_events_in_range( - &self, - calendar_id: &str, - start: DateTime, - end: DateTime + async fn list_events( + &self, + calendar_id: &str, + limit: Option, + offset: Option, ) -> Result, DomainError>; - + async fn get_events_in_range( + &self, + calendar_id: &str, + 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 + 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>; +} diff --git a/src/application/ports/carddav_ports.rs b/src/application/ports/carddav_ports.rs index 5178ffc7..8ca75dfe 100644 --- a/src/application/ports/carddav_ports.rs +++ b/src/application/ports/carddav_ports.rs @@ -1,57 +1,143 @@ -use async_trait::async_trait; -use crate::common::errors::DomainError; use crate::application::dtos::address_book_dto::{ - AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto, - ShareAddressBookDto, UnshareAddressBookDto + AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto, + UpdateAddressBookDto, }; use crate::application::dtos::contact_dto::{ - ContactDto, CreateContactDto, UpdateContactDto, CreateContactVCardDto, - ContactGroupDto, CreateContactGroupDto, UpdateContactGroupDto, GroupMembershipDto + ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto, + GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto, }; +use crate::common::errors::DomainError; +use async_trait::async_trait; pub type CardDavRepositoryError = DomainError; #[async_trait] pub trait AddressBookUseCase: Send + Sync + 'static { // Address Book operations - async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result; - async fn update_address_book(&self, address_book_id: &str, update: UpdateAddressBookDto) -> Result; - async fn delete_address_book(&self, address_book_id: &str, user_id: &str) -> Result<(), DomainError>; - async fn get_address_book(&self, address_book_id: &str, user_id: &str) -> Result; - async fn list_user_address_books(&self, user_id: &str) -> Result, DomainError>; + async fn create_address_book( + &self, + dto: CreateAddressBookDto, + ) -> Result; + async fn update_address_book( + &self, + address_book_id: &str, + update: UpdateAddressBookDto, + ) -> Result; + async fn delete_address_book( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result<(), DomainError>; + async fn get_address_book( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result; + async fn list_user_address_books( + &self, + user_id: &str, + ) -> Result, DomainError>; async fn list_public_address_books(&self) -> Result, DomainError>; - + // Address Book sharing - async fn share_address_book(&self, dto: ShareAddressBookDto, user_id: &str) -> Result<(), DomainError>; - async fn unshare_address_book(&self, dto: UnshareAddressBookDto, user_id: &str) -> Result<(), DomainError>; - async fn get_address_book_shares(&self, address_book_id: &str, user_id: &str) -> Result, DomainError>; + async fn share_address_book( + &self, + dto: ShareAddressBookDto, + user_id: &str, + ) -> Result<(), DomainError>; + async fn unshare_address_book( + &self, + dto: UnshareAddressBookDto, + user_id: &str, + ) -> Result<(), DomainError>; + async fn get_address_book_shares( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError>; } #[async_trait] pub trait ContactUseCase: Send + Sync + 'static { // Contact operations async fn create_contact(&self, dto: CreateContactDto) -> Result; - async fn create_contact_from_vcard(&self, dto: CreateContactVCardDto) -> Result; - async fn update_contact(&self, contact_id: &str, update: UpdateContactDto) -> Result; + async fn create_contact_from_vcard( + &self, + dto: CreateContactVCardDto, + ) -> Result; + async fn update_contact( + &self, + contact_id: &str, + update: UpdateContactDto, + ) -> Result; async fn delete_contact(&self, contact_id: &str, user_id: &str) -> Result<(), DomainError>; - async fn get_contact(&self, contact_id: &str, user_id: &str) -> Result; - async fn list_contacts(&self, address_book_id: &str, user_id: &str) -> Result, DomainError>; - async fn search_contacts(&self, address_book_id: &str, query: &str, user_id: &str) -> Result, DomainError>; - + async fn get_contact(&self, contact_id: &str, user_id: &str) + -> Result; + async fn list_contacts( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError>; + async fn search_contacts( + &self, + address_book_id: &str, + query: &str, + user_id: &str, + ) -> Result, DomainError>; + // Contact Group operations - async fn create_group(&self, dto: CreateContactGroupDto) -> Result; - async fn update_group(&self, group_id: &str, update: UpdateContactGroupDto) -> Result; + async fn create_group( + &self, + dto: CreateContactGroupDto, + ) -> Result; + async fn update_group( + &self, + group_id: &str, + update: UpdateContactGroupDto, + ) -> Result; async fn delete_group(&self, group_id: &str, user_id: &str) -> Result<(), DomainError>; - async fn get_group(&self, group_id: &str, user_id: &str) -> Result; - async fn list_groups(&self, address_book_id: &str, user_id: &str) -> Result, DomainError>; - + async fn get_group( + &self, + group_id: &str, + user_id: &str, + ) -> Result; + async fn list_groups( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError>; + // Group membership - async fn add_contact_to_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError>; - async fn remove_contact_from_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError>; - async fn list_contacts_in_group(&self, group_id: &str, user_id: &str) -> Result, DomainError>; - async fn list_groups_for_contact(&self, contact_id: &str, user_id: &str) -> Result, DomainError>; - + async fn add_contact_to_group( + &self, + dto: GroupMembershipDto, + user_id: &str, + ) -> Result<(), DomainError>; + async fn remove_contact_from_group( + &self, + dto: GroupMembershipDto, + user_id: &str, + ) -> Result<(), DomainError>; + async fn list_contacts_in_group( + &self, + group_id: &str, + user_id: &str, + ) -> Result, DomainError>; + async fn list_groups_for_contact( + &self, + contact_id: &str, + user_id: &str, + ) -> Result, DomainError>; + // vCard operations - async fn get_contact_vcard(&self, contact_id: &str, user_id: &str) -> Result; - async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result, DomainError>; -} \ No newline at end of file + async fn get_contact_vcard( + &self, + contact_id: &str, + user_id: &str, + ) -> Result; + async fn get_contacts_as_vcards( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError>; +} diff --git a/src/application/ports/chunked_upload_ports.rs b/src/application/ports/chunked_upload_ports.rs index 4251242f..955d800a 100644 --- a/src/application/ports/chunked_upload_ports.rs +++ b/src/application/ports/chunked_upload_ports.rs @@ -4,11 +4,11 @@ //! operations, keeping the application and interface layers independent of //! the specific upload implementation (TUS-like protocol, S3 multipart, etc.). -use std::path::PathBuf; +use crate::common::errors::DomainError; use async_trait::async_trait; use bytes::Bytes; use serde::Serialize; -use crate::common::errors::DomainError; +use std::path::PathBuf; /// Default chunk size (5 MB) — optimised for parallel transfers. pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024; @@ -80,10 +80,7 @@ pub trait ChunkedUploadPort: Send + Sync + 'static { ) -> Result; /// Get the current status of an upload session. - async fn get_status( - &self, - upload_id: &str, - ) -> Result; + async fn get_status(&self, upload_id: &str) -> Result; /// Assemble all chunks into the final file. /// @@ -94,16 +91,10 @@ pub trait ChunkedUploadPort: Send + Sync + 'static { ) -> Result<(PathBuf, String, Option, String, u64), DomainError>; /// Finalize upload: clean up the session and temporary files. - async fn finalize_upload( - &self, - upload_id: &str, - ) -> Result<(), DomainError>; + async fn finalize_upload(&self, upload_id: &str) -> Result<(), DomainError>; /// Cancel an upload and clean up all temporary data. - async fn cancel_upload( - &self, - upload_id: &str, - ) -> Result<(), DomainError>; + async fn cancel_upload(&self, upload_id: &str) -> Result<(), DomainError>; /// Check if a file size qualifies for chunked upload. fn should_use_chunked(&self, size: u64) -> bool; diff --git a/src/application/ports/compression_ports.rs b/src/application/ports/compression_ports.rs index 3540792a..2de75c57 100644 --- a/src/application/ports/compression_ports.rs +++ b/src/application/ports/compression_ports.rs @@ -4,8 +4,8 @@ //! keeping the application and interface layers independent of specific //! compression implementations (gzip, zstd, etc.). -use async_trait::async_trait; use crate::common::errors::DomainError; +use async_trait::async_trait; /// Compression level settings for file compression operations. /// @@ -30,7 +30,11 @@ pub enum CompressionLevel { #[async_trait] pub trait CompressionPort: Send + Sync + 'static { /// Compress data in memory. - async fn compress_data(&self, data: &[u8], level: CompressionLevel) -> Result, DomainError>; + async fn compress_data( + &self, + data: &[u8], + level: CompressionLevel, + ) -> Result, DomainError>; /// Decompress data in memory. async fn decompress_data(&self, compressed_data: &[u8]) -> Result, DomainError>; diff --git a/src/application/ports/dedup_ports.rs b/src/application/ports/dedup_ports.rs index 5c810ba7..8c9e36ce 100644 --- a/src/application/ports/dedup_ports.rs +++ b/src/application/ports/dedup_ports.rs @@ -4,11 +4,11 @@ //! keeping the application and interface layers independent of the specific //! content-addressable storage implementation. -use std::path::{Path, PathBuf}; +use crate::common::errors::DomainError; use async_trait::async_trait; use bytes::Bytes; use serde::Serialize; -use crate::common::errors::DomainError; +use std::path::{Path, PathBuf}; /// Metadata of a stored blob in the dedup system. #[derive(Debug, Clone, Serialize)] diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs index d68f846c..65c75e27 100644 --- a/src/application/ports/favorites_ports.rs +++ b/src/application/ports/favorites_ports.rs @@ -1,19 +1,24 @@ -use async_trait::async_trait; -use crate::common::errors::Result; use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::common::errors::Result; +use async_trait::async_trait; /// Defines operations for managing user favorites #[async_trait] pub trait FavoritesUseCase: Send + Sync { /// Get all favorites for a user async fn get_favorites(&self, user_id: &str) -> Result>; - + /// Add an item to user's favorites async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>; - + /// Remove an item from user's favorites - async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; - + async fn remove_from_favorites( + &self, + user_id: &str, + item_id: &str, + item_type: &str, + ) -> Result; + /// Check if an item is in user's favorites async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; } @@ -40,4 +45,4 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static { /// Checks if an item is in favorites. async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; -} \ No newline at end of file +} diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 72fbb734..e1a698d5 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -1,8 +1,8 @@ -use std::sync::Arc; -use std::pin::Pin; use async_trait::async_trait; use bytes::Bytes; use futures::Stream; +use std::pin::Pin; +use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; use crate::common::errors::DomainError; @@ -48,7 +48,13 @@ pub trait FileUploadUseCase: Send + Sync + 'static { ) -> Result<(FileDto, UploadStrategy), DomainError>; /// Creates a new file at the specified path (for WebDAV) - async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result; + async fn create_file( + &self, + parent_path: &str, + filename: &str, + content: &[u8], + content_type: &str, + ) -> Result; /// Updates the content of an existing file (for WebDAV) async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>; @@ -81,18 +87,21 @@ pub enum OptimizedFileContent { pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Gets a file by its ID async fn get_file(&self, id: &str) -> Result; - + /// Gets a file by its path (for WebDAV) async fn get_file_by_path(&self, path: &str) -> Result; - + /// Lists files in a folder async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; - + /// Gets file content as bytes (for small files) async fn get_file_content(&self, id: &str) -> Result, DomainError>; - + /// Gets file content as a stream (for large files) - async fn get_file_stream(&self, id: &str) -> Result> + Send>, DomainError>; + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError>; /// Optimized multi-tier download. /// @@ -123,11 +132,15 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { #[async_trait] pub trait FileManagementUseCase: Send + Sync + 'static { /// Moves a file to another folder - async fn move_file(&self, file_id: &str, folder_id: Option) -> Result; - + async fn move_file( + &self, + file_id: &str, + folder_id: Option, + ) -> Result; + /// Renames a file async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; - + /// Deletes a file async fn delete_file(&self, id: &str) -> Result<(), DomainError>; @@ -138,11 +151,7 @@ pub trait FileManagementUseCase: Send + Sync + 'static { /// 3. Decrements the dedup reference count for the content hash. /// /// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted. - async fn delete_with_cleanup( - &self, - id: &str, - user_id: &str, - ) -> Result; + async fn delete_with_cleanup(&self, id: &str, user_id: &str) -> Result; } /// Factory for creating file use case implementations @@ -150,4 +159,4 @@ pub trait FileUseCaseFactory: Send + Sync + 'static { fn create_file_upload_use_case(&self) -> Arc; fn create_file_retrieval_use_case(&self) -> Arc; fn create_file_management_use_case(&self) -> Arc; -} \ No newline at end of file +} diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index d8441a89..92cac88f 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -1,6 +1,8 @@ use async_trait::async_trait; -use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto}; +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::common::errors::DomainError; @@ -9,36 +11,37 @@ use crate::common::errors::DomainError; pub trait FolderUseCase: Send + Sync + 'static { /// Creates a new folder async fn create_folder(&self, dto: CreateFolderDto) -> Result; - + /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result; - + /// Gets a folder by its path async fn get_folder_by_path(&self, path: &str) -> Result; - + /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - + /// Lists folders with pagination async fn list_folders_paginated( - &self, + &self, parent_id: Option<&str>, - pagination: &crate::application::dtos::pagination::PaginationRequestDto + pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result, DomainError>; - + /// Renames a folder - async fn rename_folder(&self, id: &str, dto: RenameFolderDto) -> Result; - + async fn rename_folder(&self, id: &str, dto: RenameFolderDto) + -> Result; + /// Moves a folder to another parent async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result; - + /// Deletes a folder async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; } /** * Primary port for file and folder search - * + * * Defines the operations related to advanced search of * files and folders based on various criteria. */ @@ -46,16 +49,16 @@ pub trait FolderUseCase: Send + Sync + 'static { pub trait SearchUseCase: Send + Sync + 'static { /** * Performs a search based on the specified criteria - * + * * @param criteria Search criteria including text, dates, sizes, etc. * @return Search results containing matching files and folders */ async fn search(&self, criteria: SearchCriteriaDto) -> Result; - + /** * Clears the search results cache - * + * * @return Result indicating success or error */ async fn clear_search_cache(&self) -> Result<(), DomainError>; -} \ No newline at end of file +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index b8179351..5e1fd915 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -15,4 +15,4 @@ pub mod storage_ports; pub mod thumbnail_ports; pub mod transcode_ports; pub mod trash_ports; -pub mod zip_ports; \ No newline at end of file +pub mod zip_ports; diff --git a/src/application/ports/outbound.rs b/src/application/ports/outbound.rs index b63e398a..e1bd5376 100644 --- a/src/application/ports/outbound.rs +++ b/src/application/ports/outbound.rs @@ -1,8 +1,8 @@ -use std::path::PathBuf; use async_trait::async_trait; +use std::path::PathBuf; -use crate::domain::services::path_service::StoragePath; use crate::common::errors::DomainError; +use crate::domain::services::path_service::StoragePath; // Re-export domain repository traits for backward compatibility pub use crate::domain::repositories::folder_repository::FolderRepository; @@ -14,13 +14,13 @@ use super::storage_ports::{FileReadPort, FileWritePort}; pub trait StoragePort: Send + Sync + 'static { /// Resolves a domain path to a physical path fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf; - + /// Creates directories if they don't exist async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError>; - + /// Checks if a file exists at the given path async fn file_exists(&self, storage_path: &StoragePath) -> Result; - + /// Checks if a directory exists at the given path async fn directory_exists(&self, storage_path: &StoragePath) -> Result; } @@ -54,28 +54,28 @@ impl FolderStoragePort for T {} pub trait IdMappingPort: Send + Sync + 'static { /// Gets or creates an ID for a path async fn get_or_create_id(&self, path: &StoragePath) -> Result; - + /// Gets a path by its ID async fn get_path_by_id(&self, id: &str) -> Result; - + /// Updates the path for an existing ID async fn update_path(&self, id: &str, new_path: &StoragePath) -> Result<(), DomainError>; - + /// Removes an ID from the mapping async fn remove_id(&self, id: &str) -> Result<(), DomainError>; - + /// Saves pending changes async fn save_changes(&self) -> Result<(), DomainError>; - + /// Gets the file path as a PathBuf async fn get_file_path(&self, file_id: &str) -> Result { let storage_path = self.get_path_by_id(file_id).await?; Ok(PathBuf::from(storage_path.to_string())) } - + /// Updates a file's path async fn update_file_path(&self, file_id: &str, new_path: &PathBuf) -> Result<(), DomainError> { let storage_path = StoragePath::from_string(new_path.to_string_lossy().as_ref()); self.update_path(file_id, &storage_path).await } -} \ No newline at end of file +} diff --git a/src/application/ports/recent_ports.rs b/src/application/ports/recent_ports.rs index 714a4b3a..5d6156ad 100644 --- a/src/application/ports/recent_ports.rs +++ b/src/application/ports/recent_ports.rs @@ -1,19 +1,29 @@ -use async_trait::async_trait; -use crate::common::errors::Result; use crate::application::dtos::recent_dto::RecentItemDto; +use crate::common::errors::Result; +use async_trait::async_trait; /// Defines operations for managing user recent items #[async_trait] pub trait RecentItemsUseCase: Send + Sync { /// Get all recent items for a user - async fn get_recent_items(&self, user_id: &str, limit: Option) -> Result>; - + async fn get_recent_items( + &self, + user_id: &str, + limit: Option, + ) -> Result>; + /// Record access to an item - async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>; - + async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) + -> Result<()>; + /// Remove an item from recents - async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; - + async fn remove_from_recent( + &self, + user_id: &str, + item_id: &str, + item_type: &str, + ) -> Result; + /// Clear the entire recent items list async fn clear_recent_items(&self, user_id: &str) -> Result<()>; } @@ -42,4 +52,4 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static { /// Removes items exceeding `max_items` (the oldest ones). async fn prune(&self, user_id: &str, max_items: i32) -> Result<()>; -} \ No newline at end of file +} diff --git a/src/application/ports/share_ports.rs b/src/application/ports/share_ports.rs index 95be02f8..814b7213 100644 --- a/src/application/ports/share_ports.rs +++ b/src/application/ports/share_ports.rs @@ -3,13 +3,12 @@ use async_trait::async_trait; use crate::{ application::dtos::{ pagination::PaginatedResponseDto, - share_dto::{CreateShareDto, ShareDto, UpdateShareDto} + share_dto::{CreateShareDto, ShareDto, UpdateShareDto}, }, common::errors::DomainError, domain::entities::share::ShareItemType, }; - #[async_trait] pub trait ShareUseCase: Send + Sync + 'static { /// Create a new shared link for a file or folder @@ -56,30 +55,45 @@ pub trait ShareUseCase: Send + Sync + 'static { token: &str, password: &str, ) -> Result; - + /// Register an access to a shared link async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError>; } #[async_trait] pub trait ShareStoragePort: Send + Sync + 'static { - async fn save_share(&self, share: &crate::domain::entities::share::Share) - -> Result; - - async fn find_share_by_id(&self, id: &str) - -> Result; - - async fn find_share_by_token(&self, token: &str) - -> Result; - - async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) - -> Result, DomainError>; - - async fn update_share(&self, share: &crate::domain::entities::share::Share) - -> Result; - + async fn save_share( + &self, + share: &crate::domain::entities::share::Share, + ) -> Result; + + async fn find_share_by_id( + &self, + id: &str, + ) -> Result; + + async fn find_share_by_token( + &self, + token: &str, + ) -> Result; + + async fn find_shares_by_item( + &self, + item_id: &str, + item_type: &ShareItemType, + ) -> Result, DomainError>; + + async fn update_share( + &self, + share: &crate::domain::entities::share::Share, + ) -> Result; + async fn delete_share(&self, id: &str) -> Result<(), DomainError>; - - async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) - -> Result<(Vec, usize), DomainError>; + + async fn find_shares_by_user( + &self, + user_id: &str, + offset: usize, + limit: usize, + ) -> Result<(Vec, usize), DomainError>; } diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 1235af49..c5ac56fe 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -1,16 +1,18 @@ -use std::path::PathBuf; use async_trait::async_trait; use bytes::Bytes; use futures::Stream; use serde_json::Value; +use std::path::PathBuf; +use crate::common::errors::DomainError; use crate::domain::entities::file::File; use crate::domain::services::path_service::StoragePath; -use crate::common::errors::DomainError; // Re-export domain repository traits for backward compatibility. // The canonical definitions now live in domain/repositories/. -pub use crate::domain::repositories::file_repository::{FileReadRepository, FileWriteRepository, FileRepository}; +pub use crate::domain::repositories::file_repository::{ + FileReadRepository, FileRepository, FileWriteRepository, +}; pub use crate::domain::repositories::folder_repository::FolderRepository; // ───────────────────────────────────────────────────── @@ -92,17 +94,14 @@ pub trait FileWritePort: Send + Sync + 'static { ) -> Result; /// Renames a file (same folder, different name). - async fn rename_file( - &self, - file_id: &str, - new_name: &str, - ) -> Result; + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; /// Deletes a file. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; /// Updates the content of an existing file. - async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError>; + async fn update_file_content(&self, file_id: &str, content: Vec) + -> Result<(), DomainError>; /// Registers file metadata WITHOUT writing content to disk (write-behind). /// @@ -122,7 +121,11 @@ pub trait FileWritePort: Send + Sync + 'static { async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; /// Restores a file from the trash to its original location - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError>; + async fn restore_from_trash( + &self, + file_id: &str, + original_path: &str, + ) -> Result<(), DomainError>; /// Permanently deletes a file (used by the trash) async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError>; @@ -174,4 +177,4 @@ pub trait StorageUsagePort: Send + Sync + 'static { pub trait StorageUseCase: Send + Sync + 'static { /// Handle a request with the specified action and parameters async fn handle_request(&self, action: &str, params: Value) -> Result; -} \ No newline at end of file +} diff --git a/src/application/ports/thumbnail_ports.rs b/src/application/ports/thumbnail_ports.rs index 6a0e3be0..66e20268 100644 --- a/src/application/ports/thumbnail_ports.rs +++ b/src/application/ports/thumbnail_ports.rs @@ -4,11 +4,11 @@ //! keeping the application and interface layers independent of specific //! image processing implementations. -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use crate::common::errors::DomainError; use async_trait::async_trait; use bytes::Bytes; -use crate::common::errors::DomainError; +use std::path::{Path, PathBuf}; +use std::sync::Arc; /// Thumbnail sizes supported by the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -42,7 +42,11 @@ impl ThumbnailSize { /// Get all thumbnail sizes. pub fn all() -> &'static [ThumbnailSize] { - &[ThumbnailSize::Icon, ThumbnailSize::Preview, ThumbnailSize::Large] + &[ + ThumbnailSize::Icon, + ThumbnailSize::Preview, + ThumbnailSize::Large, + ] } } @@ -77,11 +81,7 @@ pub trait ThumbnailPort: Send + Sync + 'static { /// Generate all thumbnail sizes for a file in the background. /// /// Called after file upload to pre-generate thumbnails. - fn generate_all_sizes_background( - self: Arc, - file_id: String, - original_path: PathBuf, - ); + fn generate_all_sizes_background(self: Arc, file_id: String, original_path: PathBuf); /// Delete all thumbnails for a file. async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>; diff --git a/src/application/ports/transcode_ports.rs b/src/application/ports/transcode_ports.rs index e6df889c..d5d35833 100644 --- a/src/application/ports/transcode_ports.rs +++ b/src/application/ports/transcode_ports.rs @@ -4,9 +4,9 @@ //! (e.g., JPEG/PNG → WebP), keeping the application and interface layers //! independent of specific image processing implementations. +use crate::common::errors::DomainError; use async_trait::async_trait; use bytes::Bytes; -use crate::common::errors::DomainError; /// Supported output formats for image transcoding. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/application/ports/trash_ports.rs b/src/application/ports/trash_ports.rs index e9994cff..52b00dac 100644 --- a/src/application/ports/trash_ports.rs +++ b/src/application/ports/trash_ports.rs @@ -8,16 +8,16 @@ use crate::common::errors::Result; pub trait TrashUseCase: Send + Sync { /// List items in the user's trash async fn get_trash_items(&self, user_id: &str) -> Result>; - + /// Move a file or folder to trash async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: &str) -> Result<()>; - + /// Restore an item from trash to its original location async fn restore_item(&self, trash_id: &str, user_id: &str) -> Result<()>; - + /// Permanently delete an item from trash async fn delete_permanently(&self, trash_id: &str, user_id: &str) -> Result<()>; - + /// Empty the trash for a specific user async fn empty_trash(&self, user_id: &str) -> Result<()>; -} \ No newline at end of file +} diff --git a/src/application/ports/zip_ports.rs b/src/application/ports/zip_ports.rs index 9af78ac5..a640d013 100644 --- a/src/application/ports/zip_ports.rs +++ b/src/application/ports/zip_ports.rs @@ -4,8 +4,8 @@ //! keeping the interface layer independent of specific ZIP //! implementation details. -use async_trait::async_trait; use crate::common::errors::DomainError; +use async_trait::async_trait; /// Port for ZIP archive operations. /// diff --git a/src/application/services/admin_settings_service.rs b/src/application/services/admin_settings_service.rs index 5aa7a9bb..92d1d321 100644 --- a/src/application/services/admin_settings_service.rs +++ b/src/application/services/admin_settings_service.rs @@ -1,302 +1,398 @@ -use std::sync::Arc; - -use crate::domain::repositories::settings_repository::SettingsRepository; -use crate::application::services::auth_application_service::AuthApplicationService; -use crate::application::dtos::settings_dto::{ - OidcSettingsDto, SaveOidcSettingsDto, OidcTestResultDto, TestOidcConnectionDto, -}; -use crate::infrastructure::services::oidc_service::OidcService; -use crate::common::config::OidcConfig; -use crate::common::errors::{DomainError, ErrorKind}; - -/// Admin settings service — manages platform configuration in the database. -/// -/// Configuration priority: **env vars > DB settings > defaults**. -/// Supports hot-reloading OIDC configuration without server restart. -pub struct AdminSettingsService { - settings_repo: Arc, - env_oidc_config: OidcConfig, - auth_app_service: Arc, - server_base_url: String, -} - -impl AdminSettingsService { - pub fn new( - settings_repo: Arc, - env_oidc_config: OidcConfig, - auth_app_service: Arc, - server_base_url: String, - ) -> Self { - Self { - settings_repo, - env_oidc_config, - auth_app_service, - server_base_url, - } - } - - /// Auto-generated OIDC callback URL - fn callback_url(&self) -> String { - let base = self.server_base_url.trim_end_matches('/'); - format!("{}/api/auth/oidc/callback", base) - } - - /// Detect which OIDC fields are overridden by environment variables - fn get_env_overrides(&self) -> Vec { - let mut out = Vec::new(); - let vars = [ - ("OXICLOUD_OIDC_ENABLED", "enabled"), - ("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"), - ("OXICLOUD_OIDC_CLIENT_ID", "client_id"), - ("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"), - ("OXICLOUD_OIDC_SCOPES", "scopes"), - ("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"), - ("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"), - ("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN", "disable_password_login"), - ("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"), - ]; - for (env_key, field_name) in &vars { - if std::env::var(env_key).is_ok() { - out.push(field_name.to_string()); - } - } - out - } - - /// Apply environment variable overrides on top of a config - fn apply_env_overrides(&self, config: &mut OidcConfig) { - let e = &self.env_oidc_config; - if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() { config.enabled = e.enabled; } - if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() { config.issuer_url = e.issuer_url.clone(); } - if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() { config.client_id = e.client_id.clone(); } - if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() { config.client_secret = e.client_secret.clone(); } - if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() { config.scopes = e.scopes.clone(); } - if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() { config.redirect_uri = e.redirect_uri.clone(); } - if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() { config.frontend_url = e.frontend_url.clone(); } - if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() { config.auto_provision = e.auto_provision; } - if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() { config.admin_groups = e.admin_groups.clone(); } - if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() { config.disable_password_login = e.disable_password_login; } - if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() { config.provider_name = e.provider_name.clone(); } - } - - /// Load the effective OIDC config: DB settings + env var overrides + defaults. - pub async fn load_effective_oidc_config(&self) -> Result { - let db = self.settings_repo.get_by_category("oidc").await?; - let d = OidcConfig::default(); - - let mut config = OidcConfig { - enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled), - issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url), - client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id), - client_secret: db.get("oidc.client_secret").cloned().unwrap_or(d.client_secret), - redirect_uri: self.callback_url(), - scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes), - frontend_url: self.server_base_url.clone(), - auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision), - admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or(d.admin_groups), - disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login), - provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name), - }; - - // Env vars override DB - self.apply_env_overrides(&mut config); - Ok(config) - } - - /// Get OIDC settings for display in admin UI (secrets masked). - pub async fn get_oidc_settings(&self) -> Result { - let db = self.settings_repo.get_by_category("oidc").await?; - let d = OidcConfig::default(); - - let has_secret = db.get("oidc.client_secret").map(|s| !s.is_empty()).unwrap_or(false) - || std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").map(|s| !s.is_empty()).unwrap_or(false); - - Ok(OidcSettingsDto { - enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled), - issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(), - client_id: db.get("oidc.client_id").cloned().unwrap_or_default(), - client_secret_set: has_secret, - scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes), - auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision), - admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(), - disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login), - provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name), - callback_url: self.callback_url(), - env_overrides: self.get_env_overrides(), - }) - } - - /// Save OIDC settings to DB and hot-reload the OIDC service. - pub async fn save_oidc_settings( - &self, - dto: SaveOidcSettingsDto, - updated_by: &str, - ) -> Result<(), DomainError> { - let cat = "oidc"; - let by = Some(updated_by); - - self.settings_repo.set("oidc.enabled", &dto.enabled.to_string(), cat, false, by).await?; - self.settings_repo.set("oidc.issuer_url", &dto.issuer_url, cat, false, by).await?; - self.settings_repo.set("oidc.client_id", &dto.client_id, cat, false, by).await?; - - if let Some(ref secret) = dto.client_secret - && !secret.is_empty() { - self.settings_repo.set("oidc.client_secret", secret, cat, true, by).await?; - } - if let Some(ref v) = dto.scopes { - self.settings_repo.set("oidc.scopes", v, cat, false, by).await?; - } - if let Some(v) = dto.auto_provision { - self.settings_repo.set("oidc.auto_provision", &v.to_string(), cat, false, by).await?; - } - if let Some(ref v) = dto.admin_groups { - self.settings_repo.set("oidc.admin_groups", v, cat, false, by).await?; - } - if let Some(v) = dto.disable_password_login { - self.settings_repo.set("oidc.disable_password_login", &v.to_string(), cat, false, by).await?; - } - if let Some(ref v) = dto.provider_name { - self.settings_repo.set("oidc.provider_name", v, cat, false, by).await?; - } - - // Hot-reload OIDC service - let eff = self.load_effective_oidc_config().await?; - if eff.enabled && !eff.issuer_url.is_empty() - && !eff.client_id.is_empty() && !eff.client_secret.is_empty() - { - let svc = Arc::new(OidcService::new(eff.clone())); - self.auth_app_service.reload_oidc(svc, eff); - tracing::info!("OIDC service hot-reloaded with new configuration"); - } else if !eff.enabled { - self.auth_app_service.disable_oidc(); - tracing::info!("OIDC service disabled via admin panel"); - } - - Ok(()) - } - - /// Test OIDC connection by fetching the discovery document. - pub async fn test_oidc_connection( - &self, - dto: TestOidcConnectionDto, - ) -> Result { - let issuer = dto.issuer_url.trim_end_matches('/'); - let discovery_url = format!("{}/.well-known/openid-configuration", issuer); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", format!("HTTP client error: {}", e), - ))?; - - let resp = match client.get(&discovery_url).send().await { - Ok(r) => r, - Err(e) => { - return Ok(OidcTestResultDto { - success: false, - message: format!("Cannot reach the OIDC provider: {}. Check your Issuer URL.", e), - issuer: None, - authorization_endpoint: None, - token_endpoint: None, - userinfo_endpoint: None, - provider_name_suggestion: None, - }); - } - }; - - if !resp.status().is_success() { - return Ok(OidcTestResultDto { - success: false, - message: format!( - "OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.", - resp.status() - ), - issuer: None, - authorization_endpoint: None, - token_endpoint: None, - userinfo_endpoint: None, - provider_name_suggestion: None, - }); - } - - #[derive(serde::Deserialize)] - struct Discovery { - issuer: Option, - authorization_endpoint: Option, - token_endpoint: Option, - userinfo_endpoint: Option, - } - - let disc: Discovery = match resp.json().await { - Ok(d) => d, - Err(e) => { - return Ok(OidcTestResultDto { - success: false, - message: format!("Invalid discovery document: {}", e), - issuer: None, - authorization_endpoint: None, - token_endpoint: None, - userinfo_endpoint: None, - provider_name_suggestion: None, - }); - } - }; - - // Suggest provider name from hostname - let suggestion = issuer - .trim_start_matches("https://") - .trim_start_matches("http://") - .split('/') - .next() - .and_then(|host| { - let parts: Vec<&str> = host.split('.').collect(); - let name = if parts.len() >= 2 { parts[0] } else { host }; - let mut c = name.chars(); - c.next().map(|f| f.to_uppercase().to_string() + c.as_str()) - }); - - Ok(OidcTestResultDto { - success: true, - message: "OIDC provider is reachable and returned a valid discovery document.".into(), - issuer: disc.issuer, - authorization_endpoint: disc.authorization_endpoint, - token_endpoint: disc.token_endpoint, - userinfo_endpoint: disc.userinfo_endpoint, - provider_name_suggestion: suggestion, - }) - } - - // ======================================================================== - // Registration Control - // ======================================================================== - - /// Check if public self-registration is enabled. - /// Priority: env var `OXICLOUD_DISABLE_REGISTRATION` > DB setting > default (true). - pub async fn get_registration_enabled(&self) -> bool { - // Env var override takes priority - if let Ok(val) = std::env::var("OXICLOUD_DISABLE_REGISTRATION") { - return !matches!(val.to_lowercase().as_str(), "true" | "1" | "yes"); - } - // Check DB setting - match self.settings_repo.get("registration_enabled").await { - Ok(Some(val)) => val == "true", - _ => true, // default: enabled - } - } - - /// Enable or disable public self-registration. - pub async fn set_registration_enabled( - &self, - enabled: bool, - updated_by: &str, - ) -> Result<(), DomainError> { - self.settings_repo.set( - "registration_enabled", - if enabled { "true" } else { "false" }, - "general", - false, - Some(updated_by), - ).await - } -} +use std::sync::Arc; + +use crate::application::dtos::settings_dto::{ + OidcSettingsDto, OidcTestResultDto, SaveOidcSettingsDto, TestOidcConnectionDto, +}; +use crate::application::services::auth_application_service::AuthApplicationService; +use crate::common::config::OidcConfig; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::repositories::settings_repository::SettingsRepository; +use crate::infrastructure::services::oidc_service::OidcService; + +/// Admin settings service — manages platform configuration in the database. +/// +/// Configuration priority: **env vars > DB settings > defaults**. +/// Supports hot-reloading OIDC configuration without server restart. +pub struct AdminSettingsService { + settings_repo: Arc, + env_oidc_config: OidcConfig, + auth_app_service: Arc, + server_base_url: String, +} + +impl AdminSettingsService { + pub fn new( + settings_repo: Arc, + env_oidc_config: OidcConfig, + auth_app_service: Arc, + server_base_url: String, + ) -> Self { + Self { + settings_repo, + env_oidc_config, + auth_app_service, + server_base_url, + } + } + + /// Auto-generated OIDC callback URL + fn callback_url(&self) -> String { + let base = self.server_base_url.trim_end_matches('/'); + format!("{}/api/auth/oidc/callback", base) + } + + /// Detect which OIDC fields are overridden by environment variables + fn get_env_overrides(&self) -> Vec { + let mut out = Vec::new(); + let vars = [ + ("OXICLOUD_OIDC_ENABLED", "enabled"), + ("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"), + ("OXICLOUD_OIDC_CLIENT_ID", "client_id"), + ("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"), + ("OXICLOUD_OIDC_SCOPES", "scopes"), + ("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"), + ("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"), + ( + "OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN", + "disable_password_login", + ), + ("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"), + ]; + for (env_key, field_name) in &vars { + if std::env::var(env_key).is_ok() { + out.push(field_name.to_string()); + } + } + out + } + + /// Apply environment variable overrides on top of a config + fn apply_env_overrides(&self, config: &mut OidcConfig) { + let e = &self.env_oidc_config; + if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() { + config.enabled = e.enabled; + } + if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() { + config.issuer_url = e.issuer_url.clone(); + } + if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() { + config.client_id = e.client_id.clone(); + } + if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() { + config.client_secret = e.client_secret.clone(); + } + if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() { + config.scopes = e.scopes.clone(); + } + if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() { + config.redirect_uri = e.redirect_uri.clone(); + } + if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() { + config.frontend_url = e.frontend_url.clone(); + } + if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() { + config.auto_provision = e.auto_provision; + } + if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() { + config.admin_groups = e.admin_groups.clone(); + } + if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() { + config.disable_password_login = e.disable_password_login; + } + if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() { + config.provider_name = e.provider_name.clone(); + } + } + + /// Load the effective OIDC config: DB settings + env var overrides + defaults. + pub async fn load_effective_oidc_config(&self) -> Result { + let db = self.settings_repo.get_by_category("oidc").await?; + let d = OidcConfig::default(); + + let mut config = OidcConfig { + enabled: db + .get("oidc.enabled") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.enabled), + issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url), + client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id), + client_secret: db + .get("oidc.client_secret") + .cloned() + .unwrap_or(d.client_secret), + redirect_uri: self.callback_url(), + scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes), + frontend_url: self.server_base_url.clone(), + auto_provision: db + .get("oidc.auto_provision") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.auto_provision), + admin_groups: db + .get("oidc.admin_groups") + .cloned() + .unwrap_or(d.admin_groups), + disable_password_login: db + .get("oidc.disable_password_login") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.disable_password_login), + provider_name: db + .get("oidc.provider_name") + .cloned() + .unwrap_or(d.provider_name), + }; + + // Env vars override DB + self.apply_env_overrides(&mut config); + Ok(config) + } + + /// Get OIDC settings for display in admin UI (secrets masked). + pub async fn get_oidc_settings(&self) -> Result { + let db = self.settings_repo.get_by_category("oidc").await?; + let d = OidcConfig::default(); + + let has_secret = db + .get("oidc.client_secret") + .map(|s| !s.is_empty()) + .unwrap_or(false) + || std::env::var("OXICLOUD_OIDC_CLIENT_SECRET") + .map(|s| !s.is_empty()) + .unwrap_or(false); + + Ok(OidcSettingsDto { + enabled: db + .get("oidc.enabled") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.enabled), + issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(), + client_id: db.get("oidc.client_id").cloned().unwrap_or_default(), + client_secret_set: has_secret, + scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes), + auto_provision: db + .get("oidc.auto_provision") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.auto_provision), + admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(), + disable_password_login: db + .get("oidc.disable_password_login") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.disable_password_login), + provider_name: db + .get("oidc.provider_name") + .cloned() + .unwrap_or(d.provider_name), + callback_url: self.callback_url(), + env_overrides: self.get_env_overrides(), + }) + } + + /// Save OIDC settings to DB and hot-reload the OIDC service. + pub async fn save_oidc_settings( + &self, + dto: SaveOidcSettingsDto, + updated_by: &str, + ) -> Result<(), DomainError> { + let cat = "oidc"; + let by = Some(updated_by); + + self.settings_repo + .set("oidc.enabled", &dto.enabled.to_string(), cat, false, by) + .await?; + self.settings_repo + .set("oidc.issuer_url", &dto.issuer_url, cat, false, by) + .await?; + self.settings_repo + .set("oidc.client_id", &dto.client_id, cat, false, by) + .await?; + + if let Some(ref secret) = dto.client_secret + && !secret.is_empty() + { + self.settings_repo + .set("oidc.client_secret", secret, cat, true, by) + .await?; + } + if let Some(ref v) = dto.scopes { + self.settings_repo + .set("oidc.scopes", v, cat, false, by) + .await?; + } + if let Some(v) = dto.auto_provision { + self.settings_repo + .set("oidc.auto_provision", &v.to_string(), cat, false, by) + .await?; + } + if let Some(ref v) = dto.admin_groups { + self.settings_repo + .set("oidc.admin_groups", v, cat, false, by) + .await?; + } + if let Some(v) = dto.disable_password_login { + self.settings_repo + .set( + "oidc.disable_password_login", + &v.to_string(), + cat, + false, + by, + ) + .await?; + } + if let Some(ref v) = dto.provider_name { + self.settings_repo + .set("oidc.provider_name", v, cat, false, by) + .await?; + } + + // Hot-reload OIDC service + let eff = self.load_effective_oidc_config().await?; + if eff.enabled + && !eff.issuer_url.is_empty() + && !eff.client_id.is_empty() + && !eff.client_secret.is_empty() + { + let svc = Arc::new(OidcService::new(eff.clone())); + self.auth_app_service.reload_oidc(svc, eff); + tracing::info!("OIDC service hot-reloaded with new configuration"); + } else if !eff.enabled { + self.auth_app_service.disable_oidc(); + tracing::info!("OIDC service disabled via admin panel"); + } + + Ok(()) + } + + /// Test OIDC connection by fetching the discovery document. + pub async fn test_oidc_connection( + &self, + dto: TestOidcConnectionDto, + ) -> Result { + let issuer = dto.issuer_url.trim_end_matches('/'); + let discovery_url = format!("{}/.well-known/openid-configuration", issuer); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("HTTP client error: {}", e), + ) + })?; + + let resp = match client.get(&discovery_url).send().await { + Ok(r) => r, + Err(e) => { + return Ok(OidcTestResultDto { + success: false, + message: format!( + "Cannot reach the OIDC provider: {}. Check your Issuer URL.", + e + ), + issuer: None, + authorization_endpoint: None, + token_endpoint: None, + userinfo_endpoint: None, + provider_name_suggestion: None, + }); + } + }; + + if !resp.status().is_success() { + return Ok(OidcTestResultDto { + success: false, + message: format!( + "OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.", + resp.status() + ), + issuer: None, + authorization_endpoint: None, + token_endpoint: None, + userinfo_endpoint: None, + provider_name_suggestion: None, + }); + } + + #[derive(serde::Deserialize)] + struct Discovery { + issuer: Option, + authorization_endpoint: Option, + token_endpoint: Option, + userinfo_endpoint: Option, + } + + let disc: Discovery = match resp.json().await { + Ok(d) => d, + Err(e) => { + return Ok(OidcTestResultDto { + success: false, + message: format!("Invalid discovery document: {}", e), + issuer: None, + authorization_endpoint: None, + token_endpoint: None, + userinfo_endpoint: None, + provider_name_suggestion: None, + }); + } + }; + + // Suggest provider name from hostname + let suggestion = issuer + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .and_then(|host| { + let parts: Vec<&str> = host.split('.').collect(); + let name = if parts.len() >= 2 { parts[0] } else { host }; + let mut c = name.chars(); + c.next().map(|f| f.to_uppercase().to_string() + c.as_str()) + }); + + Ok(OidcTestResultDto { + success: true, + message: "OIDC provider is reachable and returned a valid discovery document.".into(), + issuer: disc.issuer, + authorization_endpoint: disc.authorization_endpoint, + token_endpoint: disc.token_endpoint, + userinfo_endpoint: disc.userinfo_endpoint, + provider_name_suggestion: suggestion, + }) + } + + // ======================================================================== + // Registration Control + // ======================================================================== + + /// Check if public self-registration is enabled. + /// Priority: env var `OXICLOUD_DISABLE_REGISTRATION` > DB setting > default (true). + pub async fn get_registration_enabled(&self) -> bool { + // Env var override takes priority + if let Ok(val) = std::env::var("OXICLOUD_DISABLE_REGISTRATION") { + return !matches!(val.to_lowercase().as_str(), "true" | "1" | "yes"); + } + // Check DB setting + match self.settings_repo.get("registration_enabled").await { + Ok(Some(val)) => val == "true", + _ => true, // default: enabled + } + } + + /// Enable or disable public self-registration. + pub async fn set_registration_enabled( + &self, + enabled: bool, + updated_by: &str, + ) -> Result<(), DomainError> { + self.settings_repo + .set( + "registration_enabled", + if enabled { "true" } else { "false" }, + "general", + false, + Some(updated_by), + ) + .await + } +} diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index f8378713..47aa1ee6 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,17 +1,22 @@ -use std::sync::Arc; -use std::sync::RwLock; -use std::collections::HashMap; -use std::sync::Mutex; -use std::time::Instant; -use std::path::PathBuf; -use crate::domain::entities::user::{User, UserRole}; -use crate::domain::entities::session::Session; -use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort, OidcServicePort, OidcIdClaims}; -use crate::application::dtos::user_dto::{UserDto, RegisterDto, LoginDto, AuthResponseDto, ChangePasswordDto, RefreshTokenDto}; use crate::application::dtos::folder_dto::CreateFolderDto; +use crate::application::dtos::user_dto::{ + AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto, +}; +use crate::application::ports::auth_ports::{ + OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, + UserStoragePort, +}; use crate::application::ports::inbound::FolderUseCase; -use crate::common::errors::{DomainError, ErrorKind}; use crate::common::config::OidcConfig; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::session::Session; +use crate::domain::entities::user::{User, UserRole}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::RwLock; +use std::time::Instant; /// Maximum age for pending OIDC flows (10 minutes) const OIDC_FLOW_TTL_SECS: u64 = 600; @@ -71,7 +76,10 @@ impl AuthApplicationService { token_service, folder_service: None, storage_path, - oidc: RwLock::new(OidcState { service: None, config: None }), + oidc: RwLock::new(OidcState { + service: None, + config: None, + }), pending_oidc_flows: Mutex::new(HashMap::new()), pending_oidc_tokens: Mutex::new(HashMap::new()), } @@ -92,7 +100,11 @@ impl AuthApplicationService { tracing::info!( "Available disk space ({} bytes) is less than default {} quota ({} bytes) — capping quota", avail_i64, - if *role == UserRole::Admin { "admin" } else { "user" }, + if *role == UserRole::Admin { + "admin" + } else { + "user" + }, base_quota, ); avail_i64 @@ -118,7 +130,7 @@ impl AuthApplicationService { } } } - + /// Configures the folder service, needed to create personal folders pub fn with_folder_service(mut self, folder_service: Arc) -> Self { self.folder_service = Some(folder_service); @@ -126,7 +138,11 @@ impl AuthApplicationService { } /// Configures the OIDC service - pub fn with_oidc(self, oidc_service: Arc, oidc_config: OidcConfig) -> Self { + pub fn with_oidc( + self, + oidc_service: Arc, + oidc_config: OidcConfig, + ) -> Self { { let mut state = self.oidc.write().unwrap(); state.service = Some(oidc_service); @@ -158,7 +174,10 @@ impl AuthApplicationService { /// Returns whether password login is disabled (OIDC-only mode) pub fn password_login_disabled(&self) -> bool { let state = self.oidc.read().unwrap(); - state.config.as_ref().is_some_and(|c| c.disable_password_login) + state + .config + .as_ref() + .is_some_and(|c| c.disable_password_login) } /// Returns a clone of the OIDC config if available @@ -172,29 +191,39 @@ impl AuthApplicationService { let state = self.oidc.read().unwrap(); state.service.clone() } - + pub async fn register(&self, dto: RegisterDto) -> Result { // Check for duplicate user - if self.user_storage.get_user_by_username(&dto.username).await.is_ok() { + if self + .user_storage + .get_user_by_username(&dto.username) + .await + .is_ok() + { return Err(DomainError::new( ErrorKind::AlreadyExists, "User", - format!("User '{}' already exists", dto.username) + format!("User '{}' already exists", dto.username), )); } - - if self.user_storage.get_user_by_email(&dto.email).await.is_ok() { + + if self + .user_storage + .get_user_by_email(&dto.email) + .await + .is_ok() + { return Err(DomainError::new( ErrorKind::AlreadyExists, "User", - format!("Email '{}' is already registered", dto.email) + format!("Email '{}' is already registered", dto.email), )); } - + // Check if the user wants to create an admin - let is_admin_request = dto.username.to_lowercase() == "admin" || - (dto.role.is_some() && dto.role.as_ref().unwrap().to_lowercase() == "admin"); - + let is_admin_request = dto.username.to_lowercase() == "admin" + || (dto.role.is_some() && dto.role.as_ref().unwrap().to_lowercase() == "admin"); + // If trying to create an admin, check if admins already exist in the system if is_admin_request { match self.count_admin_users().await { @@ -207,33 +236,41 @@ impl AuthApplicationService { Ok(user_count) => { // If there are more than 2 users (admin + test), it is not a clean install if user_count > 2 { - tracing::warn!("Attempt to create additional admin rejected: at least one admin already exists"); + tracing::warn!( + "Attempt to create additional admin rejected: at least one admin already exists" + ); return Err(DomainError::new( ErrorKind::AccessDenied, "User", - "Creating additional admin users from the registration page is not allowed" + "Creating additional admin users from the registration page is not allowed", )); } // Otherwise, it is a clean install and the first admin is allowed tracing::info!("Allowing admin creation on clean install"); - }, + } Err(e) => { // Cannot verify user count — treat as bootstrap scenario - tracing::warn!("Could not count users ({}). Allowing admin creation for bootstrap.", e); + tracing::warn!( + "Could not count users ({}). Allowing admin creation for bootstrap.", + e + ); } } } - }, + } Err(e) => { // Any DB error (table missing, connection issue, etc.) means we // cannot verify admin state. Allow admin creation so the user can // bootstrap the system. If the DB is truly broken the INSERT will // fail anyway with a clear error. - tracing::warn!("Could not count admin users ({}). Allowing admin creation for bootstrap.", e); + tracing::warn!( + "Could not count admin users ({}). Allowing admin creation for bootstrap.", + e + ); } } } - + // Determine role and quota based on user type // If an explicit "admin" role is provided, use the administrator role let role = if let Some(role_str) = &dto.role { @@ -250,118 +287,120 @@ impl AuthApplicationService { UserRole::User } }; - + // Quota based on role, capped to available disk space let quota = self.capped_quota(&role); - + // Validate password length before hashing if dto.password.len() < 8 { return Err(DomainError::new( ErrorKind::InvalidInput, "User", - "Password must be at least 8 characters long" + "Password must be at least 8 characters long", )); } - + // Hash the password using the infrastructure service let password_hash = self.password_hasher.hash_password(&dto.password)?; - + // Create user with the pre-generated hash - let user = User::new( - dto.username.clone(), - dto.email, - password_hash, - role, - quota, - ).map_err(|e| DomainError::new( - ErrorKind::InvalidInput, - "User", - format!("Error creating user: {}", e) - ))?; - + let user = User::new(dto.username.clone(), dto.email, password_hash, role, quota).map_err( + |e| { + DomainError::new( + ErrorKind::InvalidInput, + "User", + format!("Error creating user: {}", e), + ) + }, + )?; + // Save user let created_user = self.user_storage.create_user(user).await?; - + // Create personal folder for the user if let Some(folder_service) = &self.folder_service { let folder_name = format!("My Folder - {}", dto.username); - - match folder_service.create_folder(CreateFolderDto { - name: folder_name, - parent_id: None, - }).await { + + match folder_service + .create_folder(CreateFolderDto { + name: folder_name, + parent_id: None, + }) + .await + { Ok(folder) => { tracing::info!( - "Personal folder created for user {}: {} (ID: {})", - created_user.id(), - folder.name, + "Personal folder created for user {}: {} (ID: {})", + created_user.id(), + folder.name, folder.id ); - + // Here we could save the folder-to-user association, // for example, in a folder-user relationship table - }, + } Err(e) => { // We don't fail registration due to a folder creation error, // but we log it for investigation tracing::error!( - "Could not create personal folder for user {}: {}", - created_user.id(), + "Could not create personal folder for user {}: {}", + created_user.id(), e ); } } } else { tracing::warn!( - "Folder service not configured, cannot create personal folder for user: {}", + "Folder service not configured, cannot create personal folder for user: {}", created_user.id() ); } - + tracing::info!("User registered: {}", created_user.id()); Ok(UserDto::from(created_user)) } - + pub async fn login(&self, dto: LoginDto) -> Result { // Find user - let mut user = self.user_storage + let mut user = self + .user_storage .get_user_by_username(&dto.username) .await - .map_err(|_| DomainError::new( - ErrorKind::AccessDenied, - "Auth", - "Invalid credentials" - ))?; - + .map_err(|_| { + DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials") + })?; + // Check if user is active if !user.is_active() { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", - "Account deactivated" + "Account deactivated", )); } - + // Verify password using the injected hasher - let is_valid = self.password_hasher.verify_password(&dto.password, user.password_hash())?; - + let is_valid = self + .password_hasher + .verify_password(&dto.password, user.password_hash())?; + if !is_valid { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", - "Invalid credentials" + "Invalid credentials", )); } - + // Update last login user.register_login(); self.user_storage.update_user(user.clone()).await?; - + // Generate tokens using the injected token service let access_token = self.token_service.generate_access_token(&user)?; - + let refresh_token = self.token_service.generate_refresh_token(); - + // Save session let session = Session::new( user.id().to_string(), @@ -370,9 +409,9 @@ impl AuthApplicationService { None, // User-Agent (can be added from the HTTP layer) self.token_service.refresh_token_expiry_days(), ); - + self.session_storage.create_session(session).await?; - + // Authentication response Ok(AuthResponseDto { user: UserDto::from(user), @@ -382,44 +421,46 @@ impl AuthApplicationService { expires_in: self.token_service.refresh_token_expiry_secs(), }) } - - pub async fn refresh_token(&self, dto: RefreshTokenDto) -> Result { + + pub async fn refresh_token( + &self, + dto: RefreshTokenDto, + ) -> Result { // Get valid session - let session = self.session_storage + let session = self + .session_storage .get_session_by_refresh_token(&dto.refresh_token) .await?; - + // Check if the session is expired or revoked if session.is_expired() || session.is_revoked() { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", - "Session expired or invalid" + "Session expired or invalid", )); } - + // Get user - let user = self.user_storage - .get_user_by_id(session.user_id()) - .await?; - + let user = self.user_storage.get_user_by_id(session.user_id()).await?; + // Check if user is active if !user.is_active() { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", - "Account deactivated" + "Account deactivated", )); } - + // Revoke current session self.session_storage.revoke_session(session.id()).await?; - + // Generate new tokens let access_token = self.token_service.generate_access_token(&user)?; - + let new_refresh_token = self.token_service.generate_refresh_token(); - + // Create new session let new_session = Session::new( user.id().to_string(), @@ -428,9 +469,9 @@ impl AuthApplicationService { None, self.token_service.refresh_token_expiry_days(), ); - + self.session_storage.create_session(new_session).await?; - + Ok(AuthResponseDto { user: UserDto::from(user), access_token, @@ -439,119 +480,140 @@ impl AuthApplicationService { expires_in: self.token_service.refresh_token_expiry_secs(), }) } - + pub async fn logout(&self, user_id: &str, refresh_token: &str) -> Result<(), DomainError> { // Get session - let session = match self.session_storage.get_session_by_refresh_token(refresh_token).await { + let session = match self + .session_storage + .get_session_by_refresh_token(refresh_token) + .await + { Ok(s) => s, // If the session doesn't exist, we consider the logout successful Err(_) => return Ok(()), }; - + // Verify that the session belongs to the user if session.user_id() != user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", - "The session does not belong to the user" + "The session does not belong to the user", )); } - + // Revoke session self.session_storage.revoke_session(session.id()).await?; - + Ok(()) } - + pub async fn logout_all(&self, user_id: &str) -> Result { // Revoke all user sessions - let revoked_count = self.session_storage.revoke_all_user_sessions(user_id).await?; - + let revoked_count = self + .session_storage + .revoke_all_user_sessions(user_id) + .await?; + Ok(revoked_count) } - - pub async fn change_password(&self, user_id: &str, dto: ChangePasswordDto) -> Result<(), DomainError> { + + pub async fn change_password( + &self, + user_id: &str, + dto: ChangePasswordDto, + ) -> Result<(), DomainError> { // Get user let mut user = self.user_storage.get_user_by_id(user_id).await?; - + // Verify current password using the injected hasher - let is_valid = self.password_hasher.verify_password(&dto.current_password, user.password_hash())?; - + let is_valid = self + .password_hasher + .verify_password(&dto.current_password, user.password_hash())?; + if !is_valid { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", - "Current password is incorrect" + "Current password is incorrect", )); } - + // Validate new password if dto.new_password.len() < 8 { return Err(DomainError::new( ErrorKind::InvalidInput, "User", - "Password must be at least 8 characters long" + "Password must be at least 8 characters long", )); } - + // Hash new password and update user let new_hash = self.password_hasher.hash_password(&dto.new_password)?; user.update_password_hash(new_hash); - + // Save updated user self.user_storage.update_user(user).await?; - + // Optional: revoke all sessions to force re-login with new password - self.session_storage.revoke_all_user_sessions(user_id).await?; - + self.session_storage + .revoke_all_user_sessions(user_id) + .await?; + Ok(()) } - + pub async fn get_user(&self, user_id: &str) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; Ok(UserDto::from(user)) } - + // Alias for consistency with handler method pub async fn get_user_by_id(&self, user_id: &str) -> Result { self.get_user(user_id).await } - + // New method to get user by username - needed for admin user handling pub async fn get_user_by_username(&self, username: &str) -> Result { let user = self.user_storage.get_user_by_username(username).await?; Ok(UserDto::from(user)) } - + // Method to count how many admin users exist in the system // Used to determine if we have multiple admins or just the default one pub async fn count_admin_users(&self) -> Result { // Use the list_users_by_role method or similar from user_storage port // For now, we'll use a basic implementation that counts all users with role = "admin" - let admin_users = self.user_storage.list_users_by_role("admin").await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "User", - format!("Error counting admin users: {}", e) - ))?; - + let admin_users = self + .user_storage + .list_users_by_role("admin") + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "User", + format!("Error counting admin users: {}", e), + ) + })?; + Ok(admin_users.len() as i64) } - + // Method to count all users in the system // Used to determine if this is a fresh install pub async fn count_all_users(&self) -> Result { // Get all users with large limit and 0 offset - let all_users = self.user_storage.list_users(1000, 0).await - .map_err(|e| DomainError::new( + let all_users = self.user_storage.list_users(1000, 0).await.map_err(|e| { + DomainError::new( ErrorKind::InternalError, - "User", - format!("Error counting users: {}", e) - ))?; - + "User", + format!("Error counting users: {}", e), + ) + })?; + Ok(all_users.len() as i64) } - + // Method to delete the default admin user created by migrations // Used in fresh installations before creating a custom admin pub async fn delete_default_admin(&self) -> Result<(), DomainError> { @@ -559,13 +621,17 @@ impl AuthApplicationService { match self.get_user_by_username("admin").await { Ok(default_admin) => { // Delete the default admin user - self.user_storage.delete_user(&default_admin.id).await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "User", - format!("Error deleting default admin user: {}", e) - )) - }, + self.user_storage + .delete_user(&default_admin.id) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "User", + format!("Error deleting default admin user: {}", e), + ) + }) + } Err(_) => { // Admin user doesn't exist, nothing to do tracing::info!("Default admin user not found, nothing to delete"); @@ -573,27 +639,31 @@ impl AuthApplicationService { } } } - + // Method to replace the default admin user with a custom one // Used in fresh installations to allow users to set their own admin credentials pub async fn replace_default_admin(&self, dto: &RegisterDto) -> Result { // 1. Get the default admin user let default_admin = self.get_user_by_username("admin").await?; - + // 2. Delete the default admin user - self.user_storage.delete_user(&default_admin.id).await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "User", - format!("Error deleting default admin user: {}", e) - ))?; - + self.user_storage + .delete_user(&default_admin.id) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "User", + format!("Error deleting default admin user: {}", e), + ) + })?; + // 3. Create new admin user with the provided credentials but admin role let admin_role = UserRole::Admin; - + // Admin quota, capped to available disk space let admin_quota = self.capped_quota(&admin_role); - + // Create the new admin user let user = User::new( dto.username.clone(), @@ -601,45 +671,51 @@ impl AuthApplicationService { dto.password.clone(), admin_role, admin_quota, - ).map_err(|e| DomainError::new( - ErrorKind::InvalidInput, - "User", - format!("Error creating admin user: {}", e) - ))?; - + ) + .map_err(|e| { + DomainError::new( + ErrorKind::InvalidInput, + "User", + format!("Error creating admin user: {}", e), + ) + })?; + // 4. Save the new admin user let created_user = self.user_storage.create_user(user).await?; - + // 5. Create personal folder for the new admin if folder service is available if let Some(folder_service) = &self.folder_service { let folder_name = format!("My Folder - {}", dto.username); - - match folder_service.create_folder(CreateFolderDto { - name: folder_name, - parent_id: None, - }).await { + + match folder_service + .create_folder(CreateFolderDto { + name: folder_name, + parent_id: None, + }) + .await + { Ok(folder) => { tracing::info!( - "Personal folder created for admin {}: {} (ID: {})", - created_user.id(), - folder.name, + "Personal folder created for admin {}: {} (ID: {})", + created_user.id(), + folder.name, folder.id ); - }, + } Err(e) => { tracing::error!( - "Could not create personal folder for admin {}: {}", - created_user.id(), + "Could not create personal folder for admin {}: {}", + created_user.id(), e ); } } } - + tracing::info!("Custom admin created: {}", created_user.id()); Ok(UserDto::from(created_user)) } - + pub async fn list_users(&self, limit: i64, offset: i64) -> Result, DomainError> { let users = self.user_storage.list_users(limit, offset).await?; Ok(users.into_iter().map(UserDto::from).collect()) @@ -657,28 +733,37 @@ impl AuthApplicationService { // Validate username length if dto.username.len() < 3 || dto.username.len() > 32 { return Err(DomainError::new( - ErrorKind::InvalidInput, "User", + ErrorKind::InvalidInput, + "User", "Username must be between 3 and 32 characters".to_string(), )); } // Check for duplicate username - if self.user_storage.get_user_by_username(&dto.username).await.is_ok() { + if self + .user_storage + .get_user_by_username(&dto.username) + .await + .is_ok() + { return Err(DomainError::new( - ErrorKind::AlreadyExists, "User", + ErrorKind::AlreadyExists, + "User", format!("User '{}' already exists", dto.username), )); } // Email: use provided or generate placeholder - let email = dto.email + let email = dto + .email .filter(|e| !e.trim().is_empty()) .unwrap_or_else(|| format!("{}@oxicloud.local", dto.username)); // Check email uniqueness if self.user_storage.get_user_by_email(&email).await.is_ok() { return Err(DomainError::new( - ErrorKind::AlreadyExists, "User", + ErrorKind::AlreadyExists, + "User", format!("Email '{}' is already registered", email), )); } @@ -686,7 +771,8 @@ impl AuthApplicationService { // Validate password if dto.password.len() < 8 { return Err(DomainError::new( - ErrorKind::InvalidInput, "User", + ErrorKind::InvalidInput, + "User", "Password must be at least 8 characters long".to_string(), )); } @@ -699,49 +785,59 @@ impl AuthApplicationService { // Determine quota let quota = dto.quota_bytes.unwrap_or_else(|| { - if role == UserRole::Admin { 107_374_182_400 } else { 1_073_741_824 } + if role == UserRole::Admin { + 107_374_182_400 + } else { + 1_073_741_824 + } }); // Hash password let password_hash = self.password_hasher.hash_password(&dto.password)?; // Create domain entity - let user = User::new( - dto.username.clone(), - email, - password_hash, - role, - quota, - ).map_err(|e| DomainError::new( - ErrorKind::InvalidInput, "User", - format!("Error creating user: {}", e), - ))?; + let user = + User::new(dto.username.clone(), email, password_hash, role, quota).map_err(|e| { + DomainError::new( + ErrorKind::InvalidInput, + "User", + format!("Error creating user: {}", e), + ) + })?; // Persist let created = self.user_storage.create_user(user).await?; // Deactivate if requested (User::new always sets active=true) if let Some(false) = dto.active { - self.user_storage.set_user_active_status(created.id(), false).await?; + self.user_storage + .set_user_active_status(created.id(), false) + .await?; } // Create personal folder if let Some(folder_service) = &self.folder_service { let folder_name = format!("My Folder - {}", dto.username); - match folder_service.create_folder(CreateFolderDto { - name: folder_name, - parent_id: None, - }).await { + match folder_service + .create_folder(CreateFolderDto { + name: folder_name, + parent_id: None, + }) + .await + { Ok(folder) => { tracing::info!( "Personal folder created for admin-created user {}: {} (ID: {})", - created.id(), folder.name, folder.id + created.id(), + folder.name, + folder.id ); - }, + } Err(e) => { tracing::error!( "Could not create personal folder for user {}: {}", - created.id(), e + created.id(), + e ); } } @@ -759,7 +855,8 @@ impl AuthApplicationService { ) -> Result<(), DomainError> { if new_password.len() < 8 { return Err(DomainError::new( - ErrorKind::InvalidInput, "User", + ErrorKind::InvalidInput, + "User", "Password must be at least 8 characters long".to_string(), )); } @@ -783,7 +880,9 @@ impl AuthApplicationService { /// Activate or deactivate a user (admin only) pub async fn set_user_active(&self, user_id: &str, active: bool) -> Result<(), DomainError> { - self.user_storage.set_user_active_status(user_id, active).await + self.user_storage + .set_user_active_status(user_id, active) + .await } /// Change user role (admin only) @@ -799,7 +898,11 @@ impl AuthApplicationService { } /// Update user's storage quota (admin only) - pub async fn update_user_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError> { + pub async fn update_user_quota( + &self, + user_id: &str, + quota_bytes: i64, + ) -> Result<(), DomainError> { if quota_bytes < 0 { return Err(DomainError::new( ErrorKind::InvalidInput, @@ -807,11 +910,17 @@ impl AuthApplicationService { "Quota must be non-negative".to_string(), )); } - self.user_storage.update_storage_quota(user_id, quota_bytes).await + self.user_storage + .update_storage_quota(user_id, quota_bytes) + .await } /// Check if a user has enough quota for an upload of the given size - pub async fn check_quota(&self, user_id: &str, additional_bytes: i64) -> Result { + pub async fn check_quota( + &self, + user_id: &str, + additional_bytes: i64, + ) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; let quota = user.storage_quota_bytes(); if quota <= 0 { @@ -833,9 +942,13 @@ impl AuthApplicationService { /// Prepare the OIDC authorization flow: generates CSRF state, PKCE pair, /// nonce, stores them in pending_oidc_flows, and returns the authorize URL. pub async fn prepare_oidc_authorize(&self) -> Result { - let oidc = self.oidc_service().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "OIDC service not configured", - ))?; + let oidc = self.oidc_service().ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "OIDC service not configured", + ) + })?; // Generate CSRF state token use rand_core::{OsRng, RngCore}; @@ -853,7 +966,7 @@ impl AuthApplicationService { OsRng.fill_bytes(&mut verifier_bytes); let pkce_verifier = base64_url_encode(&verifier_bytes); let pkce_challenge = { - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let hash = Sha256::digest(pkce_verifier.as_bytes()); base64_url_encode(&hash) }; @@ -865,17 +978,25 @@ impl AuthApplicationService { let now = Instant::now(); flows.retain(|_, f| now.duration_since(f.created_at).as_secs() < OIDC_FLOW_TTL_SECS); - flows.insert(state_token.clone(), PendingOidcFlow { - created_at: now, - pkce_verifier, - nonce: nonce.clone(), - }); + flows.insert( + state_token.clone(), + PendingOidcFlow { + created_at: now, + pkce_verifier, + nonce: nonce.clone(), + }, + ); } // Build authorization URL with state, nonce, and PKCE challenge - let authorize_url = oidc.get_authorize_url(&state_token, &nonce, &pkce_challenge).await?; + let authorize_url = oidc + .get_authorize_url(&state_token, &nonce, &pkce_challenge) + .await?; - tracing::info!("OIDC authorize flow prepared (state={}...)", &state_token[..8]); + tracing::info!( + "OIDC authorize flow prepared (state={}...)", + &state_token[..8] + ); Ok(authorize_url) } @@ -899,7 +1020,8 @@ impl AuthApplicationService { if Instant::now().duration_since(flow.created_at).as_secs() >= OIDC_FLOW_TTL_SECS { tracing::warn!("OIDC callback with expired state token"); return Err(DomainError::new( - ErrorKind::AccessDenied, "OIDC", + ErrorKind::AccessDenied, + "OIDC", "OIDC authorization flow expired. Please try logging in again.", )); } @@ -910,12 +1032,20 @@ impl AuthApplicationService { // Clone the Arc and config out of the RwLock so we don't hold the lock across await points let (oidc, oidc_config) = { let state = self.oidc.read().unwrap(); - let svc = state.service.clone().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "OIDC service not configured", - ))?; - let cfg = state.config.clone().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "OIDC config not available", - ))?; + let svc = state.service.clone().ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "OIDC service not configured", + ) + })?; + let cfg = state.config.clone().ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "OIDC config not available", + ) + })?; (svc, cfg) }; @@ -923,7 +1053,9 @@ impl AuthApplicationService { let token_set = oidc.exchange_code(code, &pkce_verifier).await?; // 2. Validate ID token and extract claims (with nonce verification) - let claims = oidc.validate_id_token(&token_set.id_token, Some(&nonce)).await?; + let claims = oidc + .validate_id_token(&token_set.id_token, Some(&nonce)) + .await?; // 3. Try to enrich claims from UserInfo endpoint if email is missing let claims = if claims.email.is_none() { @@ -932,11 +1064,18 @@ impl AuthApplicationService { email: user_info.email.or(claims.email), preferred_username: user_info.preferred_username.or(claims.preferred_username), name: user_info.name.or(claims.name), - groups: if user_info.groups.is_empty() { claims.groups } else { user_info.groups }, + groups: if user_info.groups.is_empty() { + claims.groups + } else { + user_info.groups + }, ..claims }, Err(e) => { - tracing::warn!("Failed to fetch UserInfo (continuing with ID token claims): {}", e); + tracing::warn!( + "Failed to fetch UserInfo (continuing with ID token claims): {}", + e + ); claims } } @@ -947,14 +1086,22 @@ impl AuthApplicationService { let provider_name = oidc.provider_name().to_string(); // 4. Determine username and email - let oidc_username = claims.preferred_username.clone() + let oidc_username = claims + .preferred_username + .clone() .or(claims.name.clone()) .unwrap_or_else(|| format!("oidc_{}", &claims.sub[..8.min(claims.sub.len())])); - let oidc_email = claims.email.clone() + let oidc_email = claims + .email + .clone() .unwrap_or_else(|| format!("{}@oidc.local", oidc_username)); // 5. Look up existing user by OIDC subject - let user = match self.user_storage.get_user_by_oidc_subject(&provider_name, &claims.sub).await { + let user = match self + .user_storage + .get_user_by_oidc_subject(&provider_name, &claims.sub) + .await + { Ok(mut existing_user) => { // User exists — update last login existing_user.register_login(); @@ -968,15 +1115,20 @@ impl AuthApplicationService { if let Some(_existing) = matched_user { // Email match but no OIDC link — for security, don't auto-link return Err(DomainError::new( - ErrorKind::AlreadyExists, "OIDC", - format!("A user with email '{}' already exists. Contact admin to link your OIDC identity.", oidc_email), + ErrorKind::AlreadyExists, + "OIDC", + format!( + "A user with email '{}' already exists. Contact admin to link your OIDC identity.", + oidc_email + ), )); } // No match — JIT provision if enabled if !oidc_config.auto_provision { return Err(DomainError::new( - ErrorKind::AccessDenied, "OIDC", + ErrorKind::AccessDenied, + "OIDC", "Auto-provisioning is disabled. Contact admin to create your account.", )); } @@ -993,7 +1145,12 @@ impl AuthApplicationService { } // Check for username collision - if self.user_storage.get_user_by_username(&username).await.is_ok() { + if self + .user_storage + .get_user_by_username(&username) + .await + .is_ok() + { let suffix = &claims.sub[..4.min(claims.sub.len())]; username = format!("{}_{}", &username[..username.len().min(27)], suffix); } @@ -1005,18 +1162,27 @@ impl AuthApplicationService { quota, provider_name.clone(), claims.sub.clone(), - ).map_err(|e| DomainError::new( - ErrorKind::InvalidInput, "OIDC", - format!("Failed to create OIDC user: {}", e), - ))?; + ) + .map_err(|e| { + DomainError::new( + ErrorKind::InvalidInput, + "OIDC", + format!("Failed to create OIDC user: {}", e), + ) + })?; let created_user = self.user_storage.create_user(new_user).await?; // Create personal folder - self.create_personal_folder(&username, created_user.id()).await; + self.create_personal_folder(&username, created_user.id()) + .await; - tracing::info!("OIDC user provisioned: {} (provider: {}, sub: {})", - created_user.id(), provider_name, claims.sub); + tracing::info!( + "OIDC user provisioned: {} (provider: {}, sub: {})", + created_user.id(), + provider_name, + claims.sub + ); created_user } @@ -1055,10 +1221,13 @@ impl AuthApplicationService { let now = Instant::now(); tokens.retain(|_, t| now.duration_since(t.created_at).as_secs() < OIDC_TOKEN_TTL_SECS); - tokens.insert(exchange_code.clone(), PendingOidcToken { - auth_response, - created_at: now, - }); + tokens.insert( + exchange_code.clone(), + PendingOidcToken { + auth_response, + created_at: now, + }, + ); } tracing::info!("OIDC login successful, one-time exchange code generated"); @@ -1072,7 +1241,8 @@ impl AuthApplicationService { let mut tokens = self.pending_oidc_tokens.lock().unwrap(); let pending = tokens.remove(one_time_code).ok_or_else(|| { DomainError::new( - ErrorKind::AccessDenied, "OIDC", + ErrorKind::AccessDenied, + "OIDC", "Invalid or expired exchange code. Please try logging in again.", ) })?; @@ -1080,7 +1250,8 @@ impl AuthApplicationService { // Check TTL if Instant::now().duration_since(pending.created_at).as_secs() >= OIDC_TOKEN_TTL_SECS { return Err(DomainError::new( - ErrorKind::AccessDenied, "OIDC", + ErrorKind::AccessDenied, + "OIDC", "Exchange code expired. Please try logging in again.", )); } @@ -1106,16 +1277,27 @@ impl AuthApplicationService { async fn create_personal_folder(&self, username: &str, user_id: &str) { if let Some(folder_service) = &self.folder_service { let folder_name = format!("My Folder - {}", username); - match folder_service.create_folder(CreateFolderDto { - name: folder_name.clone(), - parent_id: None, - }).await { + match folder_service + .create_folder(CreateFolderDto { + name: folder_name.clone(), + parent_id: None, + }) + .await + { Ok(folder) => { - tracing::info!("Personal folder created for user {}: {} (ID: {})", - user_id, folder.name, folder.id); + tracing::info!( + "Personal folder created for user {}: {} (ID: {})", + user_id, + folder.name, + folder.id + ); } Err(e) => { - tracing::error!("Failed to create personal folder for user {}: {}", user_id, e); + tracing::error!( + "Failed to create personal folder for user {}: {}", + user_id, + e + ); } } } @@ -1126,4 +1308,4 @@ impl AuthApplicationService { fn base64_url_encode(input: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) -} \ No newline at end of file +} diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 9c279a8b..a26e1deb 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -1,32 +1,32 @@ +use futures::{Future, future::join_all}; use std::sync::Arc; -use futures::{future::join_all, Future}; +use thiserror::Error; use tokio::sync::Semaphore; use tracing::info; -use thiserror::Error; -use crate::application::ports::file_ports::{FileRetrievalUseCase, FileManagementUseCase}; -use crate::application::services::folder_service::FolderService; -use crate::common::errors::DomainError; -use crate::common::config::AppConfig; -use crate::application::ports::inbound::FolderUseCase; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; +use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase}; +use crate::application::ports::inbound::FolderUseCase; +use crate::application::services::folder_service::FolderService; +use crate::common::config::AppConfig; +use crate::common::errors::DomainError; /// Specific errors for batch operations #[derive(Debug, Error)] pub enum BatchOperationError { #[error("Domain error: {0}")] Domain(#[from] DomainError), - + #[error("Operation cancelled: {0}")] Cancelled(String), - + #[error("Concurrency limit exceeded: {0}")] ConcurrencyLimit(String), - + #[error("Batch operation error: {0} ({1} of {2} completed)")] PartialFailure(String, usize, usize), - + #[error("Internal error: {0}")] Internal(String), } @@ -72,11 +72,11 @@ impl BatchOperationService { file_retrieval: Arc, file_management: Arc, folder_service: Arc, - config: AppConfig + config: AppConfig, ) -> Self { // Limit concurrency based on configuration let max_concurrency = config.concurrency.max_concurrent_files; - + Self { file_retrieval, file_management, @@ -85,16 +85,21 @@ impl BatchOperationService { semaphore: Arc::new(Semaphore::new(max_concurrency)), } } - + /// Creates a new instance with default configuration pub fn default( file_retrieval: Arc, file_management: Arc, - folder_service: Arc + folder_service: Arc, ) -> Self { - Self::new(file_retrieval, file_management, folder_service, AppConfig::default()) + Self::new( + file_retrieval, + file_management, + folder_service, + AppConfig::default(), + ) } - + /// Copies multiple files in parallel pub async fn copy_files( &self, @@ -103,7 +108,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch copy of {} files", file_ids.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -113,30 +118,30 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation to perform for each file let operations = file_ids.into_iter().map(|file_id| { let mgmt = self.file_management.clone(); let target_folder = target_folder_id.clone(); let semaphore = self.semaphore.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await; - + // Release the permit explicitly (also released on drop) drop(permit); - + // Return the result along with the ID to identify successes/failures (file_id, copy_result) } }); - + // Execute all operations in parallel with concurrency control let operation_results = join_all(operations).await; - + // Process the results for (file_id, operation_result) in operation_results { match operation_result { @@ -150,22 +155,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch copy completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Moves multiple files in parallel pub async fn move_files( &self, @@ -174,7 +180,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch move of {} files", file_ids.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -184,30 +190,30 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation to perform for each file let operations = file_ids.into_iter().map(|file_id| { let mgmt = self.file_management.clone(); let target_folder = target_folder_id.clone(); let semaphore = self.semaphore.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + let move_result = mgmt.move_file(&file_id, target_folder.clone()).await; - + // Release the permit explicitly drop(permit); - + // Return the result along with the ID to identify successes/failures (file_id, move_result) } }); - + // Execute all operations in parallel with concurrency control let operation_results = join_all(operations).await; - + // Process the results for (file_id, operation_result) in operation_results { match operation_result { @@ -221,22 +227,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch move completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Deletes multiple files in parallel pub async fn delete_files( &self, @@ -244,7 +251,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch deletion of {} files", file_ids.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -254,30 +261,30 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation to perform for each file let operations = file_ids.into_iter().map(|file_id| { let mgmt = self.file_management.clone(); let semaphore = self.semaphore.clone(); let id_clone = file_id.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + let delete_result = mgmt.delete_file(&file_id).await; - + // Release the permit explicitly drop(permit); - + // Return the result along with the ID (id_clone.clone(), delete_result.map(|_| id_clone)) } }); - + // Execute all operations in parallel with concurrency control let operation_results = join_all(operations).await; - + // Process the results for (file_id, operation_result) in operation_results { match operation_result { @@ -291,22 +298,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch deletion completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Loads multiple files in parallel (data in memory) pub async fn get_multiple_files( &self, @@ -314,7 +322,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch load of {} files", file_ids.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -324,29 +332,29 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation to perform for each file let operations = file_ids.into_iter().map(|file_id| { let retrieval = self.file_retrieval.clone(); let semaphore = self.semaphore.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + let get_result = retrieval.get_file(&file_id).await; - + // Release the permit explicitly drop(permit); - + // Return the result along with the ID (file_id, get_result) } }); - + // Execute all operations in parallel with concurrency control let operation_results = join_all(operations).await; - + // Process the results for (file_id, operation_result) in operation_results { match operation_result { @@ -360,22 +368,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch load completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Deletes multiple folders in parallel pub async fn delete_folders( &self, @@ -384,7 +393,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch deletion of {} folders", folder_ids.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -394,32 +403,32 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation to perform for each folder let operations = folder_ids.into_iter().map(|folder_id| { let folder_service = self.folder_service.clone(); let semaphore = self.semaphore.clone(); let id_clone = folder_id.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + // For both recursive and non-recursive, use the standard delete_folder method // since FolderUseCase only has a single delete_folder method let delete_result = folder_service.delete_folder(&folder_id).await; - + // Release the permit explicitly drop(permit); - + // Return the result along with the ID (id_clone.clone(), delete_result.map(|_| id_clone)) } }); - + // Execute all operations in parallel with concurrency control let operation_results = join_all(operations).await; - + // Process the results for (folder_id, operation_result) in operation_results { match operation_result { @@ -433,22 +442,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch folder deletion completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Generic batch operation for any type of async function pub async fn generic_batch_operation( &self, @@ -460,9 +470,12 @@ impl BatchOperationService { F: Fn(T, Arc) -> Fut + Clone + Send + Sync + 'static, Fut: Future> + Send + 'static, { - info!("Starting generic batch operation with {} items", items.len()); + info!( + "Starting generic batch operation with {} items", + items.len() + ); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -472,25 +485,25 @@ impl BatchOperationService { ..Default::default() }, }; - + // Convert each item to a task let tasks = items.iter().map(|item| { let item_clone = item.clone(); let op = operation.clone(); let semaphore = self.semaphore.clone(); - + async move { // The provided function must handle semaphore acquisition let op_result = op(item_clone.clone(), semaphore).await; - + // Return the result along with the original item for identification (item_clone, op_result) } }); - + // Execute all tasks in parallel let operation_results = join_all(tasks).await; - + // Process results for (item, operation_result) in operation_results { match operation_result { @@ -505,22 +518,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Generic batch operation completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Create multiple folders in parallel pub async fn create_folders( &self, @@ -528,7 +542,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch creation of {} folders", folders.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -538,34 +552,34 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation for each folder let operations = folders.into_iter().map(|(name, parent_id)| { let folder_service = self.folder_service.clone(); let semaphore = self.semaphore.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + let dto = crate::application::dtos::folder_dto::CreateFolderDto { name: name.clone(), - parent_id: parent_id.clone() + parent_id: parent_id.clone(), }; let create_result = folder_service.create_folder(dto).await; - + // Release the permit explicitly drop(permit); - + // Return the result with an identifier for errors let id = format!("{}:{}", name, parent_id.unwrap_or_default()); (id, create_result) } }); - + // Execute all operations in parallel let operation_results = join_all(operations).await; - + // Process the results for (id, operation_result) in operation_results { match operation_result { @@ -579,22 +593,23 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch folder creation completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } - + /// Get metadata of multiple folders in parallel pub async fn get_multiple_folders( &self, @@ -602,7 +617,7 @@ impl BatchOperationService { ) -> Result, BatchOperationError> { info!("Starting batch load of {} folders", folder_ids.len()); let start_time = std::time::Instant::now(); - + // Create result structure let mut result = BatchResult { successful: Vec::new(), @@ -612,29 +627,29 @@ impl BatchOperationService { ..Default::default() }, }; - + // Define the operation for each folder let operations = folder_ids.into_iter().map(|folder_id| { let folder_service = self.folder_service.clone(); let semaphore = self.semaphore.clone(); - + async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - + let get_result = folder_service.get_folder(&folder_id).await; - + // Release the permit explicitly drop(permit); - + // Return the result with its ID (folder_id, get_result) } }); - + // Execute all operations in parallel let operation_results = join_all(operations).await; - + // Process the results for (folder_id, operation_result) in operation_results { match operation_result { @@ -648,19 +663,20 @@ impl BatchOperationService { } } } - + // Complete statistics result.stats.execution_time_ms = start_time.elapsed().as_millis(); - result.stats.max_concurrency = self.config.concurrency.max_concurrent_files + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files .min(result.stats.total); - + info!( "Batch folder load completed: {}/{} successful in {}ms", - result.stats.successful, - result.stats.total, - result.stats.execution_time_ms + result.stats.successful, result.stats.total, result.stats.execution_time_ms ); - + Ok(result) } } @@ -668,26 +684,26 @@ impl BatchOperationService { #[cfg(test)] mod tests { use super::*; + use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase}; use std::sync::Arc; - use crate::common::stubs::{StubFileRetrievalUseCase, StubFileManagementUseCase}; - + #[tokio::test] async fn test_generic_batch_operation() { // Create the batch service with stubs let batch_service = BatchOperationService::new( Arc::new(StubFileRetrievalUseCase), Arc::new(StubFileManagementUseCase), - Arc::new(FolderService::new( - Arc::new(crate::common::stubs::StubFolderStoragePort) - )), - AppConfig::default() + Arc::new(FolderService::new(Arc::new( + crate::common::stubs::StubFolderStoragePort, + ))), + AppConfig::default(), ); - + // Define a generic test operation let operation = |item: i32, semaphore: Arc| async move { // Acquire and release the semaphore let _permit = semaphore.acquire().await.unwrap(); - + if item % 2 == 0 { // Simulate success for even numbers Ok(item * 2) @@ -696,22 +712,25 @@ mod tests { Err(DomainError::validation_error("Odd number not allowed")) } }; - + // Execute the batch operation let items = vec![1, 2, 3, 4, 5]; - - let result = batch_service.generic_batch_operation(items, operation).await.unwrap(); - + + let result = batch_service + .generic_batch_operation(items, operation) + .await + .unwrap(); + // Verify the results assert_eq!(result.stats.total, 5); assert_eq!(result.stats.successful, 2); assert_eq!(result.stats.failed, 3); - + // Even numbers should be in successes, doubled assert!(result.successful.contains(&4)); // 2*2 assert!(result.successful.contains(&8)); // 4*2 - + // Odd numbers should be in failures assert_eq!(result.failed.len(), 3); } -} \ No newline at end of file +} diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index 00552981..e854ccd0 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -1,10 +1,10 @@ -use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; +use std::sync::Arc; use crate::application::dtos::calendar_dto::{ - CalendarDto, CalendarEventDto, CreateCalendarDto, UpdateCalendarDto, - CreateEventDto, UpdateEventDto, CreateEventICalDto + CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto, + UpdateCalendarDto, UpdateEventDto, }; use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase}; use crate::common::errors::{DomainError, ErrorKind}; @@ -15,389 +15,577 @@ pub struct CalendarService { impl CalendarService { pub fn new(calendar_storage: Arc) -> Self { - Self { - calendar_storage, - } + Self { calendar_storage } } } #[async_trait] impl CalendarUseCase for CalendarService { - async fn create_calendar(&self, calendar: CreateCalendarDto) -> Result { + async fn create_calendar( + &self, + calendar: CreateCalendarDto, + ) -> Result { // This function requires the current user context which will come from middleware // For now, we'll use a dummy implementation that needs to be completed - + // In a real implementation, get user_id from current user context - let user_id = "current_user_id"; // This should come from middleware - - self.calendar_storage.create_calendar(calendar, user_id).await + let user_id = "current_user_id"; // This should come from middleware + + self.calendar_storage + .create_calendar(calendar, user_id) + .await } - - async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result { + + async fn update_calendar( + &self, + calendar_id: &str, + update: UpdateCalendarDto, + ) -> Result { // In a real implementation, we would: // 1. Get the current user ID from middleware // 2. Verify that the user has access to this calendar // 3. Update the calendar if they have permission - - let user_id = "current_user_id"; // This should come from middleware - + + let user_id = "current_user_id"; // This should come from middleware + // Check if user has access - let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; - + 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" + "You don't have permission to update this calendar", )); } - - self.calendar_storage.update_calendar(calendar_id, update).await + + self.calendar_storage + .update_calendar(calendar_id, update) + .await } - + async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + // Check if user has access - let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; - + 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" + "You don't have permission to delete this calendar", )); } - + self.calendar_storage.delete_calendar(calendar_id).await } - + async fn get_calendar(&self, calendar_id: &str) -> Result { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + // Get the calendar let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - + // Check if user has access or if calendar is public - let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_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" + "You don't have permission to view this calendar", )); } - + Ok(calendar) } - + async fn list_my_calendars(&self) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + self.calendar_storage.list_calendars_by_owner(user_id).await } - + async fn list_shared_calendars(&self) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - - self.calendar_storage.list_calendars_shared_with_user(user_id).await + let user_id = "current_user_id"; // This should come from middleware + + self.calendar_storage + .list_calendars_shared_with_user(user_id) + .await } - - async fn list_public_calendars(&self, limit: Option, offset: Option) -> Result, DomainError> { + + async fn list_public_calendars( + &self, + limit: Option, + offset: Option, + ) -> Result, DomainError> { let limit = limit.unwrap_or(100); let offset = offset.unwrap_or(0); - - self.calendar_storage.list_public_calendars(limit, offset).await + + self.calendar_storage + .list_public_calendars(limit, offset) + .await } - - async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError> { - let current_user_id = "current_user_id"; // This should come from middleware - + + async fn share_calendar( + &self, + calendar_id: &str, + user_id: &str, + access_level: &str, + ) -> Result<(), DomainError> { + let current_user_id = "current_user_id"; // This should come from middleware + // Check if current user has access let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - + // Only the owner can share the calendar if calendar.owner_id != current_user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Calendar", - "Only the calendar owner can change sharing settings" + "Only the calendar owner can change sharing settings", )); } - + // Validate access_level match access_level { - "read" | "write" | "owner" => {}, - _ => return Err(DomainError::new( - ErrorKind::InvalidInput, - "Calendar", - format!("Invalid access level: {}. Valid values are: read, write, owner", access_level) - )), + "read" | "write" | "owner" => {} + _ => { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + format!( + "Invalid access level: {}. Valid values are: read, write, owner", + access_level + ), + )); + } } - - self.calendar_storage.share_calendar(calendar_id, user_id, access_level).await + + self.calendar_storage + .share_calendar(calendar_id, user_id, access_level) + .await } - - async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> { - let current_user_id = "current_user_id"; // This should come from middleware - + + async fn remove_calendar_sharing( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result<(), DomainError> { + let current_user_id = "current_user_id"; // This should come from middleware + // Check if current user has access let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - + // Only the owner can change sharing settings if calendar.owner_id != current_user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Calendar", - "Only the calendar owner can change sharing settings" + "Only the calendar owner can change sharing settings", )); } - - self.calendar_storage.remove_calendar_sharing(calendar_id, user_id).await + + self.calendar_storage + .remove_calendar_sharing(calendar_id, user_id) + .await } - - async fn get_calendar_shares(&self, calendar_id: &str) -> Result, DomainError> { - let current_user_id = "current_user_id"; // This should come from middleware - + + async fn get_calendar_shares( + &self, + calendar_id: &str, + ) -> Result, DomainError> { + let current_user_id = "current_user_id"; // This should come from middleware + // Check if current user has access let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - + // Only the owner can view sharing settings if calendar.owner_id != current_user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Calendar", - "Only the calendar owner can view sharing settings" + "Only the calendar owner can view sharing settings", )); } - + self.calendar_storage.get_calendar_shares(calendar_id).await } - + async fn create_event(&self, event: CreateEventDto) -> Result { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_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 add events to this calendar" + "You don't have permission to add events to this calendar", )); } - + self.calendar_storage.create_event(event).await } - - async fn create_event_from_ical(&self, event: CreateEventICalDto) -> Result { - let user_id = "current_user_id"; // This should come from middleware - + + async fn create_event_from_ical( + &self, + event: CreateEventICalDto, + ) -> Result { + let user_id = "current_user_id"; // This should come from middleware + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_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 add events to this calendar" + "You don't have permission to add events to this calendar", )); } - + self.calendar_storage.create_event_from_ical(event).await } - - async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result { - let user_id = "current_user_id"; // This should come from middleware - + + async fn update_event( + &self, + event_id: &str, + update: UpdateEventDto, + ) -> Result { + let user_id = "current_user_id"; // This should come from middleware + // Get the event to find its calendar let event = self.calendar_storage.get_event(event_id).await?; - + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_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 update events in this calendar" + "You don't have permission to update events in this calendar", )); } - + self.calendar_storage.update_event(event_id, update).await } - + async fn delete_event(&self, event_id: &str) -> Result<(), DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + // Get the event to find its calendar let event = self.calendar_storage.get_event(event_id).await?; - + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_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" + "You don't have permission to delete events in this calendar", )); } - + self.calendar_storage.delete_event(event_id).await } - + async fn get_event(&self, event_id: &str) -> Result { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + // Get the event let event = self.calendar_storage.get_event(event_id).await?; - + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?; - + let has_access = self + .calendar_storage + .check_calendar_access(&event.calendar_id, user_id) + .await?; + // Check if calendar is public - let calendar = self.calendar_storage.get_calendar(&event.calendar_id).await?; - + let calendar = self + .calendar_storage + .get_calendar(&event.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" + "You don't have permission to view events in this calendar", )); } - + Ok(event) } - - async fn list_events(&self, calendar_id: &str, limit: Option, offset: Option) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + + async fn list_events( + &self, + calendar_id: &str, + limit: Option, + offset: Option, + ) -> Result, DomainError> { + let user_id = "current_user_id"; // This should come from middleware + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; - + let has_access = self + .calendar_storage + .check_calendar_access(calendar_id, user_id) + .await?; + // Check if calendar is public 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" + "You don't have permission to view events in this calendar", )); } - + // Use pagination if provided 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 + + self.calendar_storage + .list_events_by_calendar_paginated(calendar_id, limit, offset) + .await } else { - self.calendar_storage.list_events_by_calendar(calendar_id).await + self.calendar_storage + .list_events_by_calendar(calendar_id) + .await } } - + async fn get_events_in_range( - &self, - calendar_id: &str, - start: DateTime, - end: DateTime + &self, + calendar_id: &str, + start: DateTime, + end: DateTime, ) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + let user_id = "current_user_id"; // This should come from middleware + // Check if user has access to the calendar - let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; - + let has_access = self + .calendar_storage + .check_calendar_access(calendar_id, user_id) + .await?; + // Check if calendar is public 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" + "You don't have permission to view events in this calendar", )); } - - self.calendar_storage.get_events_in_time_range(calendar_id, &start, &end).await + + 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 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?; + + 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")); + 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 + 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?; + + 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")); + 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 { + + 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?; + 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")); + 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> { + + 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?; + + 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")); + 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 + self.calendar_storage + .list_events_by_calendar_paginated(calendar_id, limit, offset) + .await } else { - self.calendar_storage.list_events_by_calendar(calendar_id).await + 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?; + + 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")); + 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 + 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?; + + 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")); + 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> { + + 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?; + 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")); + 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/contact_service.rs b/src/application/services/contact_service.rs index e144d1d9..4ff88d08 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -4,19 +4,19 @@ use sqlx::types::Uuid; use std::sync::Arc; use crate::application::dtos::address_book_dto::{ - AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto, - ShareAddressBookDto, UnshareAddressBookDto + AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto, + UpdateAddressBookDto, }; use crate::application::dtos::contact_dto::{ - ContactDto, CreateContactDto, UpdateContactDto, CreateContactVCardDto, - ContactGroupDto, CreateContactGroupDto, UpdateContactGroupDto, GroupMembershipDto + ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto, + GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto, }; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::application::ports::storage_ports::StorageUseCase; use crate::common::errors::DomainError; -use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup, Email, Phone, Address}; +use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone}; use crate::domain::repositories::address_book_repository::AddressBookRepository; -use crate::domain::repositories::contact_repository::{ContactRepository, ContactGroupRepository}; +use crate::domain::repositories::contact_repository::{ContactGroupRepository, ContactRepository}; pub struct ContactService { address_book_repository: Arc, @@ -38,8 +38,14 @@ impl ContactService { } // Helper methods - async fn check_address_book_access(&self, address_book_id: &Uuid, user_id: &str) -> Result { - let address_book = self.address_book_repository.get_address_book_by_id(address_book_id) + async fn check_address_book_access( + &self, + address_book_id: &Uuid, + user_id: &str, + ) -> Result { + let address_book = self + .address_book_repository + .get_address_book_by_id(address_book_id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; @@ -49,7 +55,10 @@ impl ContactService { } // Check if address book is shared with user - let shares = self.address_book_repository.get_address_book_shares(address_book_id).await?; + let shares = self + .address_book_repository + .get_address_book_shares(address_book_id) + .await?; if shares.iter().any(|(id, _)| id == user_id) { return Ok(address_book); } @@ -59,11 +68,19 @@ impl ContactService { return Ok(address_book); } - Err(DomainError::unauthorized("You don't have access to this address book")) + Err(DomainError::unauthorized( + "You don't have access to this address book", + )) } - async fn check_address_book_write_access(&self, address_book_id: &Uuid, user_id: &str) -> Result { - let address_book = self.address_book_repository.get_address_book_by_id(address_book_id) + async fn check_address_book_write_access( + &self, + address_book_id: &Uuid, + user_id: &str, + ) -> Result { + let address_book = self + .address_book_repository + .get_address_book_by_id(address_book_id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; @@ -73,25 +90,33 @@ impl ContactService { } // Check if address book is shared with user with write access - let shares = self.address_book_repository.get_address_book_shares(address_book_id).await?; - if shares.iter().any(|(id, can_write)| id == user_id && *can_write) { + let shares = self + .address_book_repository + .get_address_book_shares(address_book_id) + .await?; + if shares + .iter() + .any(|(id, can_write)| id == user_id && *can_write) + { return Ok(address_book); } - Err(DomainError::unauthorized("You don't have write access to this address book")) + Err(DomainError::unauthorized( + "You don't have write access to this address book", + )) } fn parse_vcard(&self, vcard_data: &str) -> Result { // This is a simplified vCard parser - a real implementation would use a proper vCard library // For now, we'll create a basic contact with minimal data - + let mut contact = Contact::default(); - + let lines: Vec<&str> = vcard_data.lines().collect(); - + for i in 0..lines.len() { let line = lines[i].trim(); - + if line.starts_with("FN:") { contact.set_full_name(Some(line[3..].to_string())); } else if line.starts_with("N:") { @@ -110,7 +135,7 @@ impl ContactService { } else { "other" }; - + contact.push_email(Email { email: value.to_string(), r#type: email_type.to_string(), @@ -131,7 +156,7 @@ impl ContactService { } else { "other" }; - + contact.push_phone(Phone { number: value.to_string(), r#type: phone_type.to_string(), @@ -148,36 +173,38 @@ impl ContactService { contact.set_uid(line[4..].to_string()); } } - + // Store the original vCard data contact.set_vcard(vcard_data.to_string()); contact.set_etag(Uuid::new_v4().to_string()); - + Ok(contact) } fn generate_vcard(&self, contact: &Contact) -> String { let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); - + // UID vcard.push_str(&format!("UID:{}\r\n", contact.uid())); - + // Name fields if let Some(full_name) = contact.full_name() { vcard.push_str(&format!("FN:{}\r\n", full_name)); } - + let last_name = contact.last_name().unwrap_or_default().to_string(); let first_name = contact.first_name().unwrap_or_default().to_string(); vcard.push_str(&format!("N:{};{};;;\r\n", last_name, first_name)); - + // Email addresses for email in contact.email() { - vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n", + vcard.push_str(&format!( + "EMAIL;TYPE={}:{}\r\n", email.r#type.to_uppercase(), - email.email)); + email.email + )); } - + // Phone numbers for phone in contact.phone() { let tel_type = match phone.r#type.as_str() { @@ -189,7 +216,7 @@ impl ContactService { }; vcard.push_str(&format!("TEL;TYPE={}:{}\r\n", tel_type, phone.number)); } - + // Addresses for addr in contact.address() { let addr_type = addr.r#type.to_uppercase(); @@ -198,43 +225,51 @@ impl ContactService { let state = addr.state.clone().unwrap_or_default(); let postal_code = addr.postal_code.clone().unwrap_or_default(); let country = addr.country.clone().unwrap_or_default(); - - vcard.push_str(&format!("ADR;TYPE={}:;;{};{};{};{};{}\r\n", - addr_type, street, city, state, postal_code, country)); + + vcard.push_str(&format!( + "ADR;TYPE={}:;;{};{};{};{};{}\r\n", + addr_type, street, city, state, postal_code, country + )); } - + // Organization if let Some(org) = contact.organization() { vcard.push_str(&format!("ORG:{}\r\n", org)); } - + // Title if let Some(title) = contact.title() { vcard.push_str(&format!("TITLE:{}\r\n", title)); } - + // Notes if let Some(notes) = contact.notes() { vcard.push_str(&format!("NOTE:{}\r\n", notes)); } - + // Birthday if let Some(birthday) = contact.birthday() { vcard.push_str(&format!("BDAY:{}\r\n", birthday.format("%Y%m%d"))); } - + // Revision (last update) - vcard.push_str(&format!("REV:{}\r\n", contact.updated_at().format("%Y%m%dT%H%M%SZ"))); - + 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 } } #[async_trait] impl AddressBookUseCase for ContactService { - async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result { + async fn create_address_book( + &self, + dto: CreateAddressBookDto, + ) -> Result { let address_book = AddressBook::new( dto.name, dto.owner_id, @@ -243,51 +278,83 @@ impl AddressBookUseCase for ContactService { dto.is_public.unwrap_or(false), ); - let created_address_book = self.address_book_repository.create_address_book(address_book).await?; + let created_address_book = self + .address_book_repository + .create_address_book(address_book) + .await?; Ok(AddressBookDto::from(created_address_book)) } - async fn update_address_book(&self, address_book_id: &str, update: UpdateAddressBookDto) -> Result { + async fn update_address_book( + &self, + address_book_id: &str, + update: UpdateAddressBookDto, + ) -> Result { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - let address_book = self.check_address_book_write_access(&id, &update.user_id).await?; + let address_book = self + .check_address_book_write_access(&id, &update.user_id) + .await?; // Apply updates let updated_address_book = AddressBook::from_raw( id, - update.name.unwrap_or_else(|| address_book.name().to_string()), + update + .name + .unwrap_or_else(|| address_book.name().to_string()), address_book.owner_id().to_string(), - update.description.or_else(|| address_book.description().map(|s| s.to_string())), - update.color.or_else(|| address_book.color().map(|s| s.to_string())), + update + .description + .or_else(|| address_book.description().map(|s| s.to_string())), + update + .color + .or_else(|| address_book.color().map(|s| s.to_string())), update.is_public.unwrap_or(address_book.is_public()), *address_book.created_at(), Utc::now(), ); - let result = self.address_book_repository.update_address_book(updated_address_book).await?; + let result = self + .address_book_repository + .update_address_book(updated_address_book) + .await?; Ok(AddressBookDto::from(result)) } - async fn delete_address_book(&self, address_book_id: &str, user_id: &str) -> Result<(), DomainError> { + async fn delete_address_book( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result<(), DomainError> { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Verify that the user is the owner of the address book - let address_book = self.address_book_repository.get_address_book_by_id(&id) + let address_book = self + .address_book_repository + .get_address_book_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; if address_book.owner_id() != user_id { - return Err(DomainError::unauthorized("Only the owner can delete an address book")); + return Err(DomainError::unauthorized( + "Only the owner can delete an address book", + )); } - self.address_book_repository.delete_address_book(&id).await?; + self.address_book_repository + .delete_address_book(&id) + .await?; Ok(()) } - async fn get_address_book(&self, address_book_id: &str, user_id: &str) -> Result { + async fn get_address_book( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; @@ -295,100 +362,154 @@ impl AddressBookUseCase for ContactService { Ok(AddressBookDto::from(address_book)) } - async fn list_user_address_books(&self, user_id: &str) -> Result, DomainError> { + async fn list_user_address_books( + &self, + user_id: &str, + ) -> Result, DomainError> { // Get address books owned by the user - let owned_address_books = self.address_book_repository.get_address_books_by_owner(user_id).await?; - + let owned_address_books = self + .address_book_repository + .get_address_books_by_owner(user_id) + .await?; + // Get address books shared with the user - let shared_address_books = self.address_book_repository.get_shared_address_books(user_id).await?; - + let shared_address_books = self + .address_book_repository + .get_shared_address_books(user_id) + .await?; + // Get public address books - let public_address_books = self.address_book_repository.get_public_address_books().await?; - + let public_address_books = self + .address_book_repository + .get_public_address_books() + .await?; + // Combine all address books, avoiding duplicates let mut address_book_map = std::collections::HashMap::new(); - + for address_book in owned_address_books { address_book_map.insert(*address_book.id(), address_book); } - + for address_book in shared_address_books { address_book_map.insert(*address_book.id(), address_book); } - + for address_book in public_address_books { - if address_book.owner_id() != user_id && !address_book_map.contains_key(address_book.id()) { + if address_book.owner_id() != user_id + && !address_book_map.contains_key(address_book.id()) + { address_book_map.insert(*address_book.id(), address_book); } } - - let address_books: Vec = address_book_map.values() + + let address_books: Vec = address_book_map + .values() .cloned() .map(AddressBookDto::from) .collect(); - + Ok(address_books) } async fn list_public_address_books(&self) -> Result, DomainError> { - let address_books = self.address_book_repository.get_public_address_books().await?; - let dtos: Vec = address_books.into_iter().map(AddressBookDto::from).collect(); + let address_books = self + .address_book_repository + .get_public_address_books() + .await?; + let dtos: Vec = address_books + .into_iter() + .map(AddressBookDto::from) + .collect(); Ok(dtos) } - async fn share_address_book(&self, dto: ShareAddressBookDto, user_id: &str) -> Result<(), DomainError> { + async fn share_address_book( + &self, + dto: ShareAddressBookDto, + user_id: &str, + ) -> Result<(), DomainError> { let id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Verify that the user is the owner of the address book - let address_book = self.address_book_repository.get_address_book_by_id(&id) + let address_book = self + .address_book_repository + .get_address_book_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; if address_book.owner_id() != user_id { - return Err(DomainError::unauthorized("Only the owner can share an address book")); + return Err(DomainError::unauthorized( + "Only the owner can share an address book", + )); } // Don't allow sharing with yourself if dto.user_id == user_id { - return Err(DomainError::validation_error("Cannot share an address book with yourself")); + return Err(DomainError::validation_error( + "Cannot share an address book with yourself", + )); } - self.address_book_repository.share_address_book(&id, &dto.user_id, dto.can_write).await?; + self.address_book_repository + .share_address_book(&id, &dto.user_id, dto.can_write) + .await?; Ok(()) } - async fn unshare_address_book(&self, dto: UnshareAddressBookDto, user_id: &str) -> Result<(), DomainError> { + async fn unshare_address_book( + &self, + dto: UnshareAddressBookDto, + user_id: &str, + ) -> Result<(), DomainError> { let id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Verify that the user is the owner of the address book - let address_book = self.address_book_repository.get_address_book_by_id(&id) + let address_book = self + .address_book_repository + .get_address_book_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; if address_book.owner_id() != user_id { - return Err(DomainError::unauthorized("Only the owner can unshare an address book")); + return Err(DomainError::unauthorized( + "Only the owner can unshare an address book", + )); } - self.address_book_repository.unshare_address_book(&id, &dto.user_id).await?; + self.address_book_repository + .unshare_address_book(&id, &dto.user_id) + .await?; Ok(()) } - async fn get_address_book_shares(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { + async fn get_address_book_shares( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Verify that the user is the owner of the address book - let address_book = self.address_book_repository.get_address_book_by_id(&id) + let address_book = self + .address_book_repository + .get_address_book_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; if address_book.owner_id() != user_id { - return Err(DomainError::unauthorized("Only the owner can view address book shares")); + return Err(DomainError::unauthorized( + "Only the owner can view address book shares", + )); } - let shares = self.address_book_repository.get_address_book_shares(&id).await?; + let shares = self + .address_book_repository + .get_address_book_shares(&id) + .await?; Ok(shares) } } @@ -400,10 +521,13 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&address_book_id, &dto.user_id).await?; + self.check_address_book_write_access(&address_book_id, &dto.user_id) + .await?; // Convert DTOs to domain entities - let email: Vec = dto.email.into_iter() + let email: Vec = dto + .email + .into_iter() .map(|e| Email { email: e.email, r#type: e.r#type, @@ -411,7 +535,9 @@ impl ContactUseCase for ContactService { }) .collect(); - let phone: Vec = dto.phone.into_iter() + let phone: Vec = dto + .phone + .into_iter() .map(|p| Phone { number: p.number, r#type: p.r#type, @@ -419,7 +545,9 @@ impl ContactUseCase for ContactService { }) .collect(); - let address: Vec
= dto.address.into_iter() + let address: Vec
= dto + .address + .into_iter() .map(|a| Address { street: a.street, city: a.city, @@ -455,51 +583,66 @@ impl ContactUseCase for ContactService { let contact_with_vcard = contact; // Create the contact - let created_contact = self.contact_repository.create_contact(contact_with_vcard).await?; + let created_contact = self + .contact_repository + .create_contact(contact_with_vcard) + .await?; Ok(ContactDto::from(created_contact)) } - async fn create_contact_from_vcard(&self, dto: CreateContactVCardDto) -> Result { + async fn create_contact_from_vcard( + &self, + dto: CreateContactVCardDto, + ) -> Result { let address_book_id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&address_book_id, &dto.user_id).await?; + self.check_address_book_write_access(&address_book_id, &dto.user_id) + .await?; // Parse vCard data let mut contact = self.parse_vcard(&dto.vcard)?; - + // Set address book ID contact.set_address_book_id(address_book_id); - + // The contact was created with Contact::default() which generates a new ID // Set creation and update timestamps let now = Utc::now(); contact.set_updated_at(now); - + // Create the contact let created_contact = self.contact_repository.create_contact(contact).await?; Ok(ContactDto::from(created_contact)) } - async fn update_contact(&self, contact_id: &str, update: UpdateContactDto) -> Result { + async fn update_contact( + &self, + contact_id: &str, + update: UpdateContactDto, + ) -> Result { let id = Uuid::parse_str(contact_id) .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the current contact - let contact = self.contact_repository.get_contact_by_id(&id) + let contact = self + .contact_repository + .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(contact.address_book_id(), &update.user_id).await?; + self.check_address_book_write_access(contact.address_book_id(), &update.user_id) + .await?; // Destructure contact into owned parts for updates let parts = contact.into_parts(); // Convert DTO fields to domain entities let email = if let Some(email_dtos) = update.email { - email_dtos.into_iter() + email_dtos + .into_iter() .map(|e| Email { email: e.email, r#type: e.r#type, @@ -511,7 +654,8 @@ impl ContactUseCase for ContactService { }; let phone = if let Some(phone_dtos) = update.phone { - phone_dtos.into_iter() + phone_dtos + .into_iter() .map(|p| Phone { number: p.number, r#type: p.r#type, @@ -523,7 +667,8 @@ impl ContactUseCase for ContactService { }; let address = if let Some(address_dtos) = update.address { - address_dtos.into_iter() + address_dtos + .into_iter() .map(|a| Address { street: a.street, city: a.city, @@ -556,7 +701,7 @@ impl ContactUseCase for ContactService { update.photo_url.or(parts.photo_url), update.birthday.or(parts.birthday), update.anniversary.or(parts.anniversary), - parts.vcard, // Will be regenerated + parts.vcard, // Will be regenerated Uuid::new_v4().to_string(), // Generate new ETag parts.created_at, Utc::now(), @@ -568,7 +713,10 @@ impl ContactUseCase for ContactService { let contact_with_vcard = updated_contact; // Update the contact - let result = self.contact_repository.update_contact(contact_with_vcard).await?; + let result = self + .contact_repository + .update_contact(contact_with_vcard) + .await?; Ok(ContactDto::from(result)) } @@ -577,34 +725,48 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the current contact - let contact = self.contact_repository.get_contact_by_id(&id) + let contact = self + .contact_repository + .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(contact.address_book_id(), user_id).await?; + self.check_address_book_write_access(contact.address_book_id(), user_id) + .await?; // Delete the contact self.contact_repository.delete_contact(&id).await?; Ok(()) } - async fn get_contact(&self, contact_id: &str, user_id: &str) -> Result { + async fn get_contact( + &self, + contact_id: &str, + user_id: &str, + ) -> Result { let id = Uuid::parse_str(contact_id) .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the contact - let contact = self.contact_repository.get_contact_by_id(&id) + let contact = self + .contact_repository + .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(contact.address_book_id(), user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id) + .await?; Ok(ContactDto::from(contact)) } - async fn list_contacts(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { + async fn list_contacts( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; @@ -612,13 +774,21 @@ impl ContactUseCase for ContactService { self.check_address_book_access(&id, user_id).await?; // Get contacts - let contacts = self.contact_repository.get_contacts_by_address_book(&id).await?; + let contacts = self + .contact_repository + .get_contacts_by_address_book(&id) + .await?; let dtos = contacts.into_iter().map(ContactDto::from).collect(); - + Ok(dtos) } - async fn search_contacts(&self, address_book_id: &str, query: &str, user_id: &str) -> Result, DomainError> { + async fn search_contacts( + &self, + address_book_id: &str, + query: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; @@ -628,37 +798,45 @@ impl ContactUseCase for ContactService { // Search contacts let contacts = self.contact_repository.search_contacts(&id, query).await?; let dtos = contacts.into_iter().map(ContactDto::from).collect(); - + Ok(dtos) } - async fn create_group(&self, dto: CreateContactGroupDto) -> Result { + async fn create_group( + &self, + dto: CreateContactGroupDto, + ) -> Result { let address_book_id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - self.check_address_book_write_access(&address_book_id, &dto.user_id).await?; + self.check_address_book_write_access(&address_book_id, &dto.user_id) + .await?; - let group = ContactGroup::new( - address_book_id, - dto.name, - ); + let group = ContactGroup::new(address_book_id, dto.name); let created_group = self.contact_group_repository.create_group(group).await?; Ok(ContactGroupDto::from(created_group)) } - async fn update_group(&self, group_id: &str, update: UpdateContactGroupDto) -> Result { + async fn update_group( + &self, + group_id: &str, + update: UpdateContactGroupDto, + ) -> Result { let id = Uuid::parse_str(group_id) .map_err(|_| DomainError::validation_error("Invalid group ID format"))?; // Get the current group - let group = self.contact_group_repository.get_group_by_id(&id) + let group = self + .contact_group_repository + .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), &update.user_id).await?; + self.check_address_book_write_access(group.address_book_id(), &update.user_id) + .await?; // Update the group let updated_group = ContactGroup::from_raw( @@ -669,7 +847,10 @@ impl ContactUseCase for ContactService { Utc::now(), ); - let result = self.contact_group_repository.update_group(updated_group).await?; + let result = self + .contact_group_repository + .update_group(updated_group) + .await?; Ok(ContactGroupDto::from(result)) } @@ -678,40 +859,57 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid group ID format"))?; // Get the current group - let group = self.contact_group_repository.get_group_by_id(&id) + let group = self + .contact_group_repository + .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), user_id).await?; + self.check_address_book_write_access(group.address_book_id(), user_id) + .await?; // Delete the group self.contact_group_repository.delete_group(&id).await?; Ok(()) } - async fn get_group(&self, group_id: &str, user_id: &str) -> Result { + async fn get_group( + &self, + group_id: &str, + user_id: &str, + ) -> Result { let id = Uuid::parse_str(group_id) .map_err(|_| DomainError::validation_error("Invalid group ID format"))?; // Get the group - let group = self.contact_group_repository.get_group_by_id(&id) + let group = self + .contact_group_repository + .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(group.address_book_id(), user_id).await?; + self.check_address_book_access(group.address_book_id(), user_id) + .await?; // Get the number of contacts in the group - let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?; - + let contacts = self + .contact_group_repository + .get_contacts_in_group(&id) + .await?; + let mut dto = ContactGroupDto::from(group); dto.members_count = Some(contacts.len() as i32); - + Ok(dto) } - async fn list_groups(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { + async fn list_groups( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; @@ -719,107 +917,159 @@ impl ContactUseCase for ContactService { self.check_address_book_access(&id, user_id).await?; // Get groups - let groups = self.contact_group_repository.get_groups_by_address_book(&id).await?; + let groups = self + .contact_group_repository + .get_groups_by_address_book(&id) + .await?; let dtos = groups.into_iter().map(ContactGroupDto::from).collect(); - + Ok(dtos) } - async fn add_contact_to_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError> { + async fn add_contact_to_group( + &self, + dto: GroupMembershipDto, + user_id: &str, + ) -> Result<(), DomainError> { let group_id = Uuid::parse_str(&dto.group_id) .map_err(|_| DomainError::validation_error("Invalid group ID format"))?; - + let contact_id = Uuid::parse_str(&dto.contact_id) .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the group - let group = self.contact_group_repository.get_group_by_id(&group_id) + let group = self + .contact_group_repository + .get_group_by_id(&group_id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), user_id).await?; + self.check_address_book_write_access(group.address_book_id(), user_id) + .await?; // Add contact to group - self.contact_group_repository.add_contact_to_group(&group_id, &contact_id).await?; + self.contact_group_repository + .add_contact_to_group(&group_id, &contact_id) + .await?; Ok(()) } - async fn remove_contact_from_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError> { + async fn remove_contact_from_group( + &self, + dto: GroupMembershipDto, + user_id: &str, + ) -> Result<(), DomainError> { let group_id = Uuid::parse_str(&dto.group_id) .map_err(|_| DomainError::validation_error("Invalid group ID format"))?; - + let contact_id = Uuid::parse_str(&dto.contact_id) .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the group - let group = self.contact_group_repository.get_group_by_id(&group_id) + let group = self + .contact_group_repository + .get_group_by_id(&group_id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), user_id).await?; + self.check_address_book_write_access(group.address_book_id(), user_id) + .await?; // Remove contact from group - self.contact_group_repository.remove_contact_from_group(&group_id, &contact_id).await?; + self.contact_group_repository + .remove_contact_from_group(&group_id, &contact_id) + .await?; Ok(()) } - async fn list_contacts_in_group(&self, group_id: &str, user_id: &str) -> Result, DomainError> { + async fn list_contacts_in_group( + &self, + group_id: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(group_id) .map_err(|_| DomainError::validation_error("Invalid group ID format"))?; // Get the group - let group = self.contact_group_repository.get_group_by_id(&id) + let group = self + .contact_group_repository + .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(group.address_book_id(), user_id).await?; + self.check_address_book_access(group.address_book_id(), user_id) + .await?; // Get contacts in group - let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?; + let contacts = self + .contact_group_repository + .get_contacts_in_group(&id) + .await?; let dtos = contacts.into_iter().map(ContactDto::from).collect(); - + Ok(dtos) } - async fn list_groups_for_contact(&self, contact_id: &str, user_id: &str) -> Result, DomainError> { + async fn list_groups_for_contact( + &self, + contact_id: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(contact_id) .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the contact - let contact = self.contact_repository.get_contact_by_id(&id) + let contact = self + .contact_repository + .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(contact.address_book_id(), user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id) + .await?; // Get groups for contact - let groups = self.contact_group_repository.get_groups_for_contact(&id).await?; + let groups = self + .contact_group_repository + .get_groups_for_contact(&id) + .await?; let dtos = groups.into_iter().map(ContactGroupDto::from).collect(); - + Ok(dtos) } - async fn get_contact_vcard(&self, contact_id: &str, user_id: &str) -> Result { + async fn get_contact_vcard( + &self, + contact_id: &str, + user_id: &str, + ) -> Result { let id = Uuid::parse_str(contact_id) .map_err(|_| DomainError::validation_error("Invalid contact ID format"))?; // Get the contact - let contact = self.contact_repository.get_contact_by_id(&id) + let contact = self + .contact_repository + .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(contact.address_book_id(), user_id).await?; + self.check_address_book_access(contact.address_book_id(), user_id) + .await?; // Return the vCard data Ok(contact.vcard().to_string()) } - async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { + async fn get_contacts_as_vcards( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; @@ -827,284 +1077,360 @@ impl ContactUseCase for ContactService { self.check_address_book_access(&id, user_id).await?; // Get all contacts in the address book - let contacts = self.contact_repository.get_contacts_by_address_book(&id).await?; - + let contacts = self + .contact_repository + .get_contacts_by_address_book(&id) + .await?; + // Convert to Vec<(id, vcard)> - let vcards = contacts.into_iter() + let vcards = contacts + .into_iter() .map(|contact| (contact.id().to_string(), contact.vcard().to_string())) .collect(); - + Ok(vcards) } } #[async_trait] impl StorageUseCase for ContactService { - async fn handle_request(&self, action: &str, params: serde_json::Value) -> Result { + async fn handle_request( + &self, + action: &str, + params: serde_json::Value, + ) -> Result { match action { // Address Book operations "create_address_book" => { - let dto: CreateAddressBookDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + let dto: CreateAddressBookDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.create_address_book(dto).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "update_address_book" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let update: UpdateAddressBookDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let update: UpdateAddressBookDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.update_address_book(address_book_id, update).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "delete_address_book" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let user_id = params["user_id"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.delete_address_book(address_book_id, user_id).await?; Ok(serde_json::Value::Null) - }, + } "get_address_book" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let user_id = params["user_id"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.get_address_book(address_book_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "list_user_address_books" => { - let user_id = params["user_id"].as_str() + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.list_user_address_books(user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "list_public_address_books" => { let result = self.list_public_address_books().await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "share_address_book" => { - let dto: ShareAddressBookDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - - let user_id = params["user_id"].as_str() + let dto: ShareAddressBookDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.share_address_book(dto, user_id).await?; Ok(serde_json::Value::Null) - }, + } "unshare_address_book" => { - let dto: UnshareAddressBookDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - - let user_id = params["user_id"].as_str() + let dto: UnshareAddressBookDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.unshare_address_book(dto, user_id).await?; Ok(serde_json::Value::Null) - }, + } "get_address_book_shares" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let user_id = params["user_id"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - - let result = self.get_address_book_shares(address_book_id, user_id).await?; + + let result = self + .get_address_book_shares(address_book_id, user_id) + .await?; Ok(serde_json::to_value(result).unwrap()) - }, + } // Contact operations "create_contact" => { - let dto: CreateContactDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + let dto: CreateContactDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.create_contact(dto).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "create_contact_from_vcard" => { - let dto: CreateContactVCardDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + let dto: CreateContactVCardDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.create_contact_from_vcard(dto).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "update_contact" => { - let contact_id = params["contact_id"].as_str() + let contact_id = params["contact_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let update: UpdateContactDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + + let update: UpdateContactDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.update_contact(contact_id, update).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "delete_contact" => { - let contact_id = params["contact_id"].as_str() + let contact_id = params["contact_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.delete_contact(contact_id, user_id).await?; Ok(serde_json::Value::Null) - }, + } "get_contact" => { - let contact_id = params["contact_id"].as_str() + let contact_id = params["contact_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.get_contact(contact_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "list_contacts" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let user_id = params["user_id"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.list_contacts(address_book_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "search_contacts" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let query = params["query"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let query = params["query"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing query parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - - let result = self.search_contacts(address_book_id, query, user_id).await?; + + let result = self + .search_contacts(address_book_id, query, user_id) + .await?; Ok(serde_json::to_value(result).unwrap()) - }, + } // Group operations "create_group" => { - let dto: CreateContactGroupDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + let dto: CreateContactGroupDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.create_group(dto).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "update_group" => { - let group_id = params["group_id"].as_str() + let group_id = params["group_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - + let update: UpdateContactGroupDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - + .map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + let result = self.update_group(group_id, update).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "delete_group" => { - let group_id = params["group_id"].as_str() + let group_id = params["group_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.delete_group(group_id, user_id).await?; Ok(serde_json::Value::Null) - }, + } "get_group" => { - let group_id = params["group_id"].as_str() + let group_id = params["group_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.get_group(group_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "list_groups" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let user_id = params["user_id"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.list_groups(address_book_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } // Group membership operations "add_contact_to_group" => { - let dto: GroupMembershipDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - - let user_id = params["user_id"].as_str() + let dto: GroupMembershipDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.add_contact_to_group(dto, user_id).await?; Ok(serde_json::Value::Null) - }, + } "remove_contact_from_group" => { - let dto: GroupMembershipDto = serde_json::from_value(params.clone()) - .map_err(|e| DomainError::validation_error(format!("Invalid parameters: {}", e)))?; - - let user_id = params["user_id"].as_str() + let dto: GroupMembershipDto = + serde_json::from_value(params.clone()).map_err(|e| { + DomainError::validation_error(format!("Invalid parameters: {}", e)) + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + self.remove_contact_from_group(dto, user_id).await?; Ok(serde_json::Value::Null) - }, + } "list_contacts_in_group" => { - let group_id = params["group_id"].as_str() + let group_id = params["group_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.list_contacts_in_group(group_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "list_groups_for_contact" => { - let contact_id = params["contact_id"].as_str() + let contact_id = params["contact_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.list_groups_for_contact(contact_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } // vCard operations "get_contact_vcard" => { - let contact_id = params["contact_id"].as_str() + let contact_id = params["contact_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"].as_str() + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - + let result = self.get_contact_vcard(contact_id, user_id).await?; Ok(serde_json::to_value(result).unwrap()) - }, + } "get_contacts_as_vcards" => { - let address_book_id = params["address_book_id"].as_str() - .ok_or_else(|| DomainError::validation_error("Missing address_book_id parameter"))?; - - let user_id = params["user_id"].as_str() + let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { + DomainError::validation_error("Missing address_book_id parameter") + })?; + + let user_id = params["user_id"] + .as_str() .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - - let result = self.get_contacts_as_vcards(address_book_id, user_id).await?; + + let result = self + .get_contacts_as_vcards(address_book_id, user_id) + .await?; Ok(serde_json::to_value(result).unwrap()) - }, - - _ => Err(DomainError::validation_error(format!("Unknown action: {}", action))), + } + + _ => Err(DomainError::validation_error(format!( + "Unknown action: {}", + action + ))), } } -} \ No newline at end of file +} diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index d3ba2754..c46298af 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -1,9 +1,9 @@ -use std::sync::Arc; -use async_trait::async_trait; -use tracing::info; -use crate::common::errors::{Result, DomainError, ErrorKind}; -use crate::application::ports::favorites_ports::{FavoritesUseCase, FavoritesRepositoryPort}; use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; +use crate::common::errors::{DomainError, ErrorKind, Result}; +use async_trait::async_trait; +use std::sync::Arc; +use tracing::info; /// Implementation of the FavoritesUseCase for managing user favorites. /// @@ -26,13 +26,20 @@ impl FavoritesUseCase for FavoritesService { async fn get_favorites(&self, user_id: &str) -> Result> { info!("Getting favorites for user: {}", user_id); let favorites = self.repo.get_favorites(user_id).await?; - info!("Retrieved {} favorites for user {}", favorites.len(), user_id); + info!( + "Retrieved {} favorites for user {}", + favorites.len(), + user_id + ); Ok(favorites) } /// Add an item to user's favorites async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { - info!("Adding {} '{}' to favorites for user {}", item_type, item_id, user_id); + info!( + "Adding {} '{}' to favorites for user {}", + item_type, item_id, user_id + ); if item_type != "file" && item_type != "folder" { return Err(DomainError::new( @@ -43,25 +50,48 @@ impl FavoritesUseCase for FavoritesService { } self.repo.add_favorite(user_id, item_id, item_type).await?; - info!("Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id); + info!( + "Successfully added {} '{}' to favorites for user {}", + item_type, item_id, user_id + ); Ok(()) } /// Remove an item from user's favorites - async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { - info!("Removing {} '{}' from favorites for user {}", item_type, item_id, user_id); - let removed = self.repo.remove_favorite(user_id, item_id, item_type).await?; + async fn remove_from_favorites( + &self, + user_id: &str, + item_id: &str, + item_type: &str, + ) -> Result { + info!( + "Removing {} '{}' from favorites for user {}", + item_type, item_id, user_id + ); + let removed = self + .repo + .remove_favorite(user_id, item_id, item_type) + .await?; info!( "{} {} '{}' from favorites for user {}", - if removed { "Successfully removed" } else { "Did not find" }, - item_type, item_id, user_id + if removed { + "Successfully removed" + } else { + "Did not find" + }, + item_type, + item_id, + user_id ); Ok(removed) } /// Check if an item is in user's favorites async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { - info!("Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id); + info!( + "Checking if {} '{}' is favorite for user {}", + item_type, item_id, user_id + ); self.repo.is_favorite(user_id, item_id, item_type).await } -} \ No newline at end of file +} diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 4eb5ba55..8cc689cf 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -1,13 +1,13 @@ -use std::sync::Arc; use async_trait::async_trait; +use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::file_ports::FileManagementUseCase; -use crate::application::ports::storage_ports::{FileWritePort, FileReadPort}; use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::file_ports::FileManagementUseCase; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::DomainError; -use tracing::{debug, info, warn, error}; +use tracing::{debug, error, info, warn}; /// Service for file management operations (move, delete). /// @@ -76,9 +76,14 @@ impl FileManagementService { /// Decrement dedup reference count; log result. async fn decrement_dedup_ref(&self, hash: &str) { - let Some(dedup) = &self.dedup_service else { return }; + let Some(dedup) = &self.dedup_service else { + return; + }; match dedup.remove_reference(hash).await { - Ok(true) => info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]), + Ok(true) => info!( + "🗑️ DEDUP: Blob {} deleted (no more references)", + &hash[..12] + ), Ok(false) => debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]), Err(e) => warn!("⚠️ DEDUP: Failed to decrement reference: {}", e), } @@ -92,12 +97,19 @@ impl FileManagementUseCase for FileManagementService { file_id: &str, folder_id: Option, ) -> Result { - info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id); + info!( + "Moving file with ID: {} to folder: {:?}", + file_id, folder_id + ); - let moved_file = self.file_repository.move_file(file_id, folder_id).await.map_err(|e| { - error!("Error moving file (ID: {}): {}", file_id, e); - e - })?; + let moved_file = self + .file_repository + .move_file(file_id, folder_id) + .await + .map_err(|e| { + error!("Error moving file (ID: {}): {}", file_id, e); + e + })?; info!( "File moved successfully: {} (ID: {}) to folder: {:?}", @@ -109,17 +121,17 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(moved_file)) } - async fn rename_file( - &self, - file_id: &str, - new_name: &str, - ) -> Result { + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { info!("Renaming file with ID: {} to \"{}\"", file_id, new_name); - let renamed_file = self.file_repository.rename_file(file_id, new_name).await.map_err(|e| { - error!("Error renaming file (ID: {}): {}", file_id, e); - e - })?; + let renamed_file = self + .file_repository + .rename_file(file_id, new_name) + .await + .map_err(|e| { + error!("Error renaming file (ID: {}): {}", file_id, e); + e + })?; info!( "File renamed successfully: {} (ID: {})", @@ -135,11 +147,7 @@ impl FileManagementUseCase for FileManagementService { } /// Smart delete: trash-first with dedup reference cleanup. - async fn delete_with_cleanup( - &self, - id: &str, - user_id: &str, - ) -> Result { + async fn delete_with_cleanup(&self, id: &str, user_id: &str) -> Result { // Step 1: Compute content hash for dedup tracking let content_hash = self.compute_content_hash(id).await; @@ -175,4 +183,4 @@ impl FileManagementUseCase for FileManagementService { Ok(false) // permanently deleted } -} \ No newline at end of file +} diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 2ad493ba..d832cf3d 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -1,12 +1,12 @@ -use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; use futures::Stream; +use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; +use crate::application::ports::cache_ports::{ContentCachePort, WriteBehindCachePort}; use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent}; use crate::application::ports::storage_ports::FileReadPort; -use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort}; use crate::application::ports::transcode_ports::{ImageTranscodePort, OutputFormat}; use crate::common::errors::DomainError; use tracing::{debug, info, warn}; @@ -114,7 +114,10 @@ impl FileRetrievalUseCase for FileRetrievalService { } } - Err(DomainError::not_found("File", format!("not found at path: {}", path))) + Err(DomainError::not_found( + "File", + format!("not found at path: {}", path), + )) } async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { @@ -150,44 +153,69 @@ impl FileRetrievalUseCase for FileRetrievalService { // ── Tier 0: Write-behind cache ─────────────────────── if let Some(wb) = &self.write_behind - && let Some(pending) = wb.get_pending(id).await { - debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file_name, pending.len()); - let (data, mime) = if do_transcode { - if let Some((t, m)) = self.try_transcode(id, &pending, &mime_type, file_size, true).await { - (t, m) - } else { - (pending, mime_type.clone()) - } + && let Some(pending) = wb.get_pending(id).await + { + debug!( + "⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", + file_name, + pending.len() + ); + let (data, mime) = if do_transcode { + if let Some((t, m)) = self + .try_transcode(id, &pending, &mime_type, file_size, true) + .await + { + (t, m) } else { (pending, mime_type.clone()) - }; - return Ok((dto, OptimizedFileContent::Bytes { + } + } else { + (pending, mime_type.clone()) + }; + return Ok(( + dto, + OptimizedFileContent::Bytes { data, mime_type: mime, was_transcoded: do_transcode, - })); - } + }, + )); + } // ── Tier 1: Hot cache + transcode (<10 MB) ────────── if file_size < CACHE_THRESHOLD { // Check content cache first if let Some(cache) = &self.content_cache - && let Some((cached, _etag, _ct)) = cache.get(id).await { - debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file_name, cached.len()); - if do_transcode - && let Some((t, m)) = self.try_transcode(id, &cached, &mime_type, file_size, true).await { - return Ok((dto, OptimizedFileContent::Bytes { - data: t, - mime_type: m, - was_transcoded: true, - })); - } - return Ok((dto, OptimizedFileContent::Bytes { + && let Some((cached, _etag, _ct)) = cache.get(id).await + { + debug!( + "🔥 TIER 1 Cache HIT: {} ({} bytes)", + file_name, + cached.len() + ); + if do_transcode + && let Some((t, m)) = self + .try_transcode(id, &cached, &mime_type, file_size, true) + .await + { + return Ok(( + dto, + OptimizedFileContent::Bytes { + data: t, + mime_type: m, + was_transcoded: true, + }, + )); + } + return Ok(( + dto, + OptimizedFileContent::Bytes { data: cached, mime_type: mime_type.clone(), was_transcoded: false, - })); - } + }, + )); + } // Cache miss – load from disk debug!("💾 TIER 1 Cache MISS: {} – loading from disk", file_name); @@ -197,27 +225,47 @@ impl FileRetrievalUseCase for FileRetrievalService { // Store in cache if let Some(cache) = &self.content_cache { let etag = format!("\"{}-{}\"", id, modified_at); - cache.put(id.to_string(), content_bytes.clone(), etag, mime_type.clone()).await; + cache + .put( + id.to_string(), + content_bytes.clone(), + etag, + mime_type.clone(), + ) + .await; } if do_transcode - && let Some((t, m)) = self.try_transcode(id, &content_bytes, &mime_type, file_size, true).await { - return Ok((dto, OptimizedFileContent::Bytes { + && let Some((t, m)) = self + .try_transcode(id, &content_bytes, &mime_type, file_size, true) + .await + { + return Ok(( + dto, + OptimizedFileContent::Bytes { data: t, mime_type: m, was_transcoded: true, - })); - } - return Ok((dto, OptimizedFileContent::Bytes { - data: content_bytes, - mime_type: mime_type.clone(), - was_transcoded: false, - })); + }, + )); + } + return Ok(( + dto, + OptimizedFileContent::Bytes { + data: content_bytes, + mime_type: mime_type.clone(), + was_transcoded: false, + }, + )); } // ── Tier 2: MMAP (10–100 MB) ──────────────────────── if file_size < MMAP_THRESHOLD { - info!("🗺️ TIER 2 MMAP: {} ({} MB)", file_name, file_size / (1024 * 1024)); + info!( + "🗺️ TIER 2 MMAP: {} ({} MB)", + file_name, + file_size / (1024 * 1024) + ); match self.file_read.get_file_mmap(id).await { Ok(mmap_content) => { return Ok((dto, OptimizedFileContent::Mmap(mmap_content))); @@ -230,17 +278,24 @@ impl FileRetrievalUseCase for FileRetrievalService { } // ── Tier 3: Streaming (≥100 MB) ───────────────────── - info!("📡 TIER 3 STREAMING: {} ({} MB)", file_name, file_size / (1024 * 1024)); + info!( + "📡 TIER 3 STREAMING: {} ({} MB)", + file_name, + file_size / (1024 * 1024) + ); match self.file_read.get_file_stream(id).await { Ok(stream) => Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream)))), Err(e) => { warn!("Streaming failed, last-resort content load: {}", e); let content = self.file_read.get_file_content(id).await?; - Ok((dto, OptimizedFileContent::Bytes { - data: Bytes::from(content), - mime_type: mime_type.clone(), - was_transcoded: false, - })) + Ok(( + dto, + OptimizedFileContent::Bytes { + data: Bytes::from(content), + mime_type: mime_type.clone(), + was_transcoded: false, + }, + )) } } } @@ -254,4 +309,4 @@ impl FileRetrievalUseCase for FileRetrievalService { ) -> Result> + Send>, DomainError> { self.file_read.get_file_range_stream(id, start, end).await } -} \ No newline at end of file +} diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 1ce676e0..059ea262 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -1,14 +1,14 @@ -use std::sync::Arc; -use std::pin::Pin; use async_trait::async_trait; use bytes::Bytes; use futures::Stream; +use std::pin::Pin; +use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy}; -use crate::application::ports::storage_ports::{FileWritePort, FileReadPort}; use crate::application::ports::cache_ports::WriteBehindCachePort; use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy}; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::common::errors::DomainError; use tracing::{debug, info, warn}; @@ -47,7 +47,8 @@ pub struct FileUploadService { /// Optional dedup service for content-addressable storage dedup: Option>, /// Optional storage usage tracking - storage_usage_service: Option>, + storage_usage_service: + Option>, } impl FileUploadService { @@ -92,7 +93,10 @@ impl FileUploadService { /// Run dedup tracking (non-fatal on failure). async fn run_dedup(&self, data: &[u8], content_type: &str) { let Some(dedup) = &self.dedup else { return }; - match dedup.store_bytes(data, Some(content_type.to_string())).await { + match dedup + .store_bytes(data, Some(content_type.to_string())) + .await + { Ok(result) => { if result.was_deduplicated() { info!( @@ -101,7 +105,10 @@ impl FileUploadService { result.size() ); } else { - info!("💾 DEDUP: new content stored (hash: {})", &result.hash()[..12]); + info!( + "💾 DEDUP: new content stored (hash: {})", + &result.hash()[..12] + ); } } Err(e) => { @@ -119,7 +126,10 @@ impl FileUploadService { let service_clone = Arc::clone(storage_service); tokio::spawn(async move { match service_clone.update_user_storage_usage(&username).await { - Ok(usage) => debug!("Updated storage usage for user {} to {} bytes", username, usage), + Ok(usage) => debug!( + "Updated storage usage for user {} to {} bytes", + username, usage + ), Err(e) => warn!("Failed to update storage usage for {}: {}", username, e), } }); @@ -138,7 +148,10 @@ impl FileUploadUseCase for FileUploadService { content_type: String, content: Vec, ) -> Result { - let file = self.file_write.save_file(name, folder_id, content_type, content).await?; + let file = self + .file_write + .save_file(name, folder_id, content_type, content) + .await?; let dto = FileDto::from(file); self.maybe_update_storage_usage(&dto); Ok(dto) @@ -170,34 +183,38 @@ impl FileUploadUseCase for FileUploadService { // ─── TIER 1: Write-Behind (<256 KB) ────────────────── if total_size < WRITE_BEHIND_THRESHOLD && let Some(wb) = &self.write_behind - && wb.is_eligible_size(total_size) { - let data: Bytes = if chunks.len() == 1 { - chunks.into_iter().next().unwrap() - } else { - let mut combined = Vec::with_capacity(total_size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - combined.into() - }; - - let (file, target_path) = self - .file_write - .register_file_deferred(name.clone(), folder_id, content_type, total_size as u64) - .await?; - let dto = FileDto::from(file); - - if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await { - return Err(DomainError::internal_error("file", format!( - "Write-behind cache failed: {}", - e - ))); - } - - info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", name, dto.id); - self.maybe_update_storage_usage(&dto); - return Ok((dto, UploadStrategy::WriteBehind)); + && wb.is_eligible_size(total_size) + { + let data: Bytes = if chunks.len() == 1 { + chunks.into_iter().next().unwrap() + } else { + let mut combined = Vec::with_capacity(total_size); + for chunk in chunks { + combined.extend_from_slice(&chunk); } + combined.into() + }; + + let (file, target_path) = self + .file_write + .register_file_deferred(name.clone(), folder_id, content_type, total_size as u64) + .await?; + let dto = FileDto::from(file); + + if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await { + return Err(DomainError::internal_error( + "file", + format!("Write-behind cache failed: {}", e), + )); + } + + info!( + "⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", + name, dto.id + ); + self.maybe_update_storage_usage(&dto); + return Ok((dto, UploadStrategy::WriteBehind)); + } // ─── TIER 2: Streaming (≥1 MB) ────────────────────── if total_size >= STREAMING_UPLOAD_THRESHOLD { @@ -310,4 +327,4 @@ impl FileUploadUseCase for FileUploadService { .await?; Ok(()) } -} \ No newline at end of file +} diff --git a/src/application/services/file_use_case_factory.rs b/src/application/services/file_use_case_factory.rs index 877d9a68..1e1373d6 100644 --- a/src/application/services/file_use_case_factory.rs +++ b/src/application/services/file_use_case_factory.rs @@ -1,10 +1,12 @@ use std::sync::Arc; -use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory}; -use crate::application::services::file_upload_service::FileUploadService; -use crate::application::services::file_retrieval_service::FileRetrievalService; -use crate::application::services::file_management_service::FileManagementService; +use crate::application::ports::file_ports::{ + FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, +}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::services::file_management_service::FileManagementService; +use crate::application::services::file_retrieval_service::FileRetrievalService; +use crate::application::services::file_upload_service::FileUploadService; /// Factory for creating file use case implementations pub struct AppFileUseCaseFactory { @@ -16,7 +18,7 @@ impl AppFileUseCaseFactory { /// Creates a new factory for file use cases pub fn new( file_read_repository: Arc, - file_write_repository: Arc + file_write_repository: Arc, ) -> Self { Self { file_read_repository, @@ -29,12 +31,14 @@ impl FileUseCaseFactory for AppFileUseCaseFactory { fn create_file_upload_use_case(&self) -> Arc { Arc::new(FileUploadService::new(self.file_write_repository.clone())) } - + fn create_file_retrieval_use_case(&self) -> Arc { Arc::new(FileRetrievalService::new(self.file_read_repository.clone())) } - + fn create_file_management_use_case(&self) -> Arc { - Arc::new(FileManagementService::new(self.file_write_repository.clone())) + Arc::new(FileManagementService::new( + self.file_write_repository.clone(), + )) } -} \ No newline at end of file +} diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 5e10013f..805c7857 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -1,11 +1,13 @@ -use std::sync::Arc; -use async_trait::async_trait; -use crate::domain::services::path_service::StoragePath; -use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto, FolderDto}; +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::outbound::FolderStoragePort; use crate::application::transactions::storage_transaction::StorageTransaction; use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::services::path_service::StoragePath; +use async_trait::async_trait; +use std::sync::Arc; /// Implementation of the use case for folder operations pub struct FolderService { @@ -17,55 +19,71 @@ impl FolderService { pub fn new(folder_storage: Arc) -> Self { Self { folder_storage } } - + /// Creates a stub implementation for testing and middleware pub fn new_stub() -> impl FolderUseCase { struct FolderServiceStub; - + #[async_trait] impl FolderUseCase for FolderServiceStub { async fn create_folder(&self, _dto: CreateFolderDto) -> Result { Ok(FolderDto::empty()) } - + async fn get_folder(&self, _id: &str) -> Result { Ok(FolderDto::empty()) } - + async fn get_folder_by_path(&self, _path: &str) -> Result { Ok(FolderDto::empty()) } - - async fn list_folders(&self, _parent_id: Option<&str>) -> Result, DomainError> { + + async fn list_folders( + &self, + _parent_id: Option<&str>, + ) -> Result, DomainError> { Ok(vec![]) } - + async fn list_folders_paginated( - &self, + &self, _parent_id: Option<&str>, - _pagination: &crate::application::dtos::pagination::PaginationRequestDto - ) -> Result, DomainError> { - Ok(crate::application::dtos::pagination::PaginatedResponseDto::new( - vec![], - 0, - 10, - 0 - )) + _pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result< + crate::application::dtos::pagination::PaginatedResponseDto, + DomainError, + > { + Ok( + crate::application::dtos::pagination::PaginatedResponseDto::new( + vec![], + 0, + 10, + 0, + ), + ) } - - async fn rename_folder(&self, _id: &str, _dto: RenameFolderDto) -> Result { + + async fn rename_folder( + &self, + _id: &str, + _dto: RenameFolderDto, + ) -> Result { Ok(FolderDto::empty()) } - - async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result { + + async fn move_folder( + &self, + _id: &str, + _dto: MoveFolderDto, + ) -> Result { Ok(FolderDto::empty()) } - + async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { Ok(()) } } - + FolderServiceStub } } @@ -79,10 +97,10 @@ impl FolderUseCase for FolderService { return Err(DomainError::new( ErrorKind::InvalidInput, "Folder", - "Folder name cannot be empty" + "Folder name cannot be empty", )); } - + // If a parent_id is provided, verify it exists if let Some(parent_id) = &dto.parent_id { let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok(); @@ -90,105 +108,147 @@ impl FolderUseCase for FolderService { return Err(DomainError::not_found("Folder", parent_id)); } } - + // Create the folder - let folder = self.folder_storage.create_folder(dto.name, dto.parent_id) + let folder = self + .folder_storage + .create_folder(dto.name, dto.parent_id) .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to create folder: {}", e)))?; - + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to create folder: {}", e), + ) + })?; + // Convert to DTO Ok(FolderDto::from(folder)) } - + /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result { - let folder = self.folder_storage.get_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {}: {}", id, e)))?; - + let folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get folder with ID: {}: {}", id, e), + ) + })?; + Ok(FolderDto::from(folder)) } - + /// Gets a folder by its path async fn get_folder_by_path(&self, path: &str) -> Result { // Convert the string path to StoragePath let storage_path = StoragePath::from_string(path); - - let folder = self.folder_storage.get_folder_by_path(&storage_path) + + let folder = self + .folder_storage + .get_folder_by_path(&storage_path) .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder at path: {}: {}", path, e)))?; - + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get folder at path: {}: {}", path, e), + ) + })?; + Ok(FolderDto::from(folder)) } - + /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { - let folders = self.folder_storage.list_folders(parent_id) + let folders = self + .folder_storage + .list_folders(parent_id) .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to list folders in parent: {:?}: {}", parent_id, e)))?; - + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to list folders in parent: {:?}: {}", parent_id, e), + ) + })?; + // Convert to DTOs Ok(folders.into_iter().map(FolderDto::from).collect()) } - + /// Lists folders with pagination async fn list_folders_paginated( - &self, + &self, parent_id: Option<&str>, - pagination: &crate::application::dtos::pagination::PaginationRequestDto - ) -> Result, DomainError> { + pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result, DomainError> + { // Validate and adjust pagination let pagination = pagination.validate_and_adjust(); - + // Get paginated folders and total count - let (folders, total_items) = self.folder_storage.list_folders_paginated( - parent_id, - pagination.offset(), - pagination.limit(), - true // Always include total for better UX - ) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to list folders with pagination in parent: {:?}: {}", parent_id, e)))?; - + let (folders, total_items) = self + .folder_storage + .list_folders_paginated( + parent_id, + pagination.offset(), + pagination.limit(), + true, // Always include total for better UX + ) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!( + "Failed to list folders with pagination in parent: {:?}: {}", + parent_id, e + ), + ) + })?; + // The total is needed to calculate pagination let total = total_items.unwrap_or(folders.len()); - + // Convert to PaginatedResponseDto let response = crate::application::dtos::pagination::PaginatedResponseDto::new( folders.into_iter().map(FolderDto::from).collect(), pagination.page, pagination.page_size, - total + total, ); - + Ok(response) } - + /// Renames a folder - async fn rename_folder(&self, id: &str, dto: RenameFolderDto) -> Result { + async fn rename_folder( + &self, + id: &str, + dto: RenameFolderDto, + ) -> Result { // Input validation if dto.name.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, "Folder", - "New folder name cannot be empty" + "New folder name cannot be empty", )); } - + // Verify the folder exists - let existing_folder = self.folder_storage.get_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {} for renaming: {}", id, e)))?; - + let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get folder with ID: {} for renaming: {}", id, e), + ) + })?; + // Create transaction for renaming let mut transaction = StorageTransaction::new("rename_folder"); - + // Main operation: rename folder // Clone all values to avoid lifetime issues let folder_storage = self.folder_storage.clone(); let id_owned = id.to_string(); let name_owned = dto.name.clone(); - + // Create future with owned values let rename_op = async move { folder_storage.rename_folder(&id_owned, name_owned).await?; @@ -198,40 +258,50 @@ impl FolderUseCase for FolderService { let original_name = existing_folder.name().to_string(); let storage = self.folder_storage.clone(); let id_clone = id.to_string(); - + async move { // In case of failure, restore the original name - storage.rename_folder(&id_clone, original_name).await + storage + .rename_folder(&id_clone, original_name) + .await .map(|_| ()) - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "Folder", - format!("Failed to rollback folder rename: {}", e) - )) + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Folder", + format!("Failed to rollback folder rename: {}", e), + ) + }) } }; - + // Add to the transaction transaction.add_operation(rename_op, rollback_op); - + // Execute transaction transaction.commit().await?; - + // Get the renamed folder - let folder = self.folder_storage.get_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get renamed folder with ID: {}: {}", id, e)))?; - + let folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get renamed folder with ID: {}: {}", id, e), + ) + })?; + Ok(FolderDto::from(folder)) } - + /// Moves a folder to a new parent async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result { // Verify the source folder exists - let source_folder = self.folder_storage.get_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {} for moving: {}", id, e)))?; - + let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get folder with ID: {} for moving: {}", id, e), + ) + })?; + // If a parent_id is specified, verify it exists if let Some(parent_id) = &dto.parent_id { // Verify we are not trying to move the folder into itself or one of its descendants @@ -239,29 +309,29 @@ impl FolderUseCase for FolderService { return Err(DomainError::new( ErrorKind::InvalidInput, "Folder", - "Cannot move a folder into itself" + "Cannot move a folder into itself", )); } - + // Verify the destination exists let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok(); if !parent_exists { return Err(DomainError::not_found("Folder", parent_id)); } - + // TODO: Ideally we should verify the entire hierarchy to prevent cycles } - + // Create transaction for moving let mut transaction = StorageTransaction::new("move_folder"); - + // Main operation: move folder // Clone all values to avoid lifetime issues let folder_storage = self.folder_storage.clone(); let id_owned = id.to_string(); // Get parent ID as owned string or None let parent_id_owned = dto.parent_id.as_ref().map(|p| p.to_string()); - + // Create future with owned values let move_op = async move { // Convert Option to Option<&str> @@ -273,45 +343,58 @@ impl FolderUseCase for FolderService { let original_parent_id = source_folder.parent_id().map(String::from); let storage = self.folder_storage.clone(); let id_clone = id.to_string(); - + async move { // In case of failure, restore the original location - storage.move_folder(&id_clone, original_parent_id.as_deref()).await + storage + .move_folder(&id_clone, original_parent_id.as_deref()) + .await .map(|_| ()) - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "Folder", - format!("Failed to rollback folder move: {}", e) - )) + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Folder", + format!("Failed to rollback folder move: {}", e), + ) + }) } }; - + // Add to the transaction transaction.add_operation(move_op, rollback_op); - + // Execute transaction transaction.commit().await?; - + // Get the moved folder - let folder = self.folder_storage.get_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get moved folder with ID: {}: {}", id, e)))?; - + let folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get moved folder with ID: {}: {}", id, e), + ) + })?; + Ok(FolderDto::from(folder)) } - + /// Deletes a folder async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { // Verify the folder exists - let _folder = self.folder_storage.get_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {} for deletion: {}", id, e)))?; - + let _folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to get folder with ID: {} for deletion: {}", id, e), + ) + })?; + // In a real implementation, we could verify permissions, dependencies, etc. - + // Delete the folder - self.folder_storage.delete_folder(id) - .await - .map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to delete folder with ID: {}: {}", id, e))) + self.folder_storage.delete_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to delete folder with ID: {}: {}", id, e), + ) + }) } -} \ No newline at end of file +} diff --git a/src/application/services/i18n_application_service.rs b/src/application/services/i18n_application_service.rs index 3a4b8817..5d89980e 100644 --- a/src/application/services/i18n_application_service.rs +++ b/src/application/services/i18n_application_service.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::domain::services::i18n_service::{I18nService, I18nResult, Locale}; +use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale}; /// Service for i18n operations pub struct I18nApplicationService { @@ -11,65 +11,67 @@ impl I18nApplicationService { /// Creates a dummy service for testing pub fn dummy() -> Self { struct DummyI18nService; - + #[async_trait::async_trait] impl I18nService for DummyI18nService { async fn translate(&self, _key: &str, _locale: Locale) -> I18nResult { Ok("DUMMY_TRANSLATION".to_string()) } - + async fn load_translations(&self, _locale: Locale) -> I18nResult<()> { Ok(()) } - + async fn available_locales(&self) -> Vec { vec![Locale::English, Locale::Spanish] } - + async fn is_supported(&self, _locale: Locale) -> bool { true } } - - Self { i18n_service: Arc::new(DummyI18nService) } + + Self { + i18n_service: Arc::new(DummyI18nService), + } } - + /// Creates a new i18n application service pub fn new(i18n_service: Arc) -> Self { Self { i18n_service } } - + /// Get a translation for a key and locale pub async fn translate(&self, key: &str, locale: Option) -> I18nResult { let locale = locale.unwrap_or(Locale::default()); self.i18n_service.translate(key, locale).await } - + /// Load translations for a locale pub async fn load_translations(&self, locale: Locale) -> I18nResult<()> { self.i18n_service.load_translations(locale).await } - + /// Load translations for all available locales pub async fn load_all_translations(&self) -> Vec<(Locale, I18nResult<()>)> { let locales = self.i18n_service.available_locales().await; let mut results = Vec::new(); - + for locale in locales { let result = self.i18n_service.load_translations(locale).await; results.push((locale, result)); } - + results } - + /// Get available locales pub async fn available_locales(&self) -> Vec { self.i18n_service.available_locales().await } - + /// Check if a locale is supported pub async fn is_supported(&self, locale: Locale) -> bool { self.i18n_service.is_supported(locale).await } -} \ No newline at end of file +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 1b9647e3..7f9a3328 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -21,7 +21,7 @@ pub mod trash_service; mod trash_service_test; // Re-exportar para facilitar acceso -pub use file_upload_service::FileUploadService; -pub use file_retrieval_service::FileRetrievalService; pub use file_management_service::FileManagementService; +pub use file_retrieval_service::FileRetrievalService; +pub use file_upload_service::FileUploadService; pub use file_use_case_factory::AppFileUseCaseFactory; diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index 30d4090f..7c9ecb49 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -1,9 +1,9 @@ -use std::sync::Arc; -use async_trait::async_trait; -use tracing::info; -use crate::common::errors::{Result, DomainError, ErrorKind}; -use crate::application::ports::recent_ports::{RecentItemsUseCase, RecentItemsRepositoryPort}; use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; +use crate::common::errors::{DomainError, ErrorKind, Result}; +use async_trait::async_trait; +use std::sync::Arc; +use tracing::info; /// Implementation of the use case for managing recent items. /// @@ -27,17 +27,35 @@ impl RecentService { #[async_trait] impl RecentItemsUseCase for RecentService { /// Get recent items for a user - async fn get_recent_items(&self, user_id: &str, limit: Option) -> Result> { + async fn get_recent_items( + &self, + user_id: &str, + limit: Option, + ) -> Result> { info!("Getting recent items for user: {}", user_id); - let limit_value = limit.unwrap_or(self.max_recent_items).min(self.max_recent_items); + let limit_value = limit + .unwrap_or(self.max_recent_items) + .min(self.max_recent_items); let items = self.repo.get_recent_items(user_id, limit_value).await?; - info!("Retrieved {} recent items for user {}", items.len(), user_id); + info!( + "Retrieved {} recent items for user {}", + items.len(), + user_id + ); Ok(items) } /// Record access to an item - async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { - info!("Recording access to {} '{}' for user {}", item_type, item_id, user_id); + async fn record_item_access( + &self, + user_id: &str, + item_id: &str, + item_type: &str, + ) -> Result<()> { + info!( + "Recording access to {} '{}' for user {}", + item_type, item_id, user_id + ); if item_type != "file" && item_type != "folder" { return Err(DomainError::new( @@ -50,18 +68,35 @@ impl RecentItemsUseCase for RecentService { self.repo.upsert_access(user_id, item_id, item_type).await?; self.repo.prune(user_id, self.max_recent_items).await?; - info!("Successfully recorded access to {} '{}' for user {}", item_type, item_id, user_id); + info!( + "Successfully recorded access to {} '{}' for user {}", + item_type, item_id, user_id + ); Ok(()) } /// Remove an item from recent - async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { - info!("Removing {} '{}' from recent for user {}", item_type, item_id, user_id); + async fn remove_from_recent( + &self, + user_id: &str, + item_id: &str, + item_type: &str, + ) -> Result { + info!( + "Removing {} '{}' from recent for user {}", + item_type, item_id, user_id + ); let removed = self.repo.remove_item(user_id, item_id, item_type).await?; info!( "{} {} '{}' from recent items for user {}", - if removed { "Successfully removed" } else { "Not found" }, - item_type, item_id, user_id + if removed { + "Successfully removed" + } else { + "Not found" + }, + item_type, + item_id, + user_id ); Ok(removed) } @@ -73,4 +108,4 @@ impl RecentItemsUseCase for RecentService { info!("Cleared all recent items for user {}", user_id); Ok(()) } -} \ No newline at end of file +} diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index 340bec35..e9f1d74a 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -1,21 +1,21 @@ -use std::sync::Arc; -use std::collections::HashMap; -use std::time::{Duration, Instant}; -use std::sync::Mutex; use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use tokio::time; -use crate::common::errors::Result; -use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; +use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::application::ports::inbound::SearchUseCase; use crate::application::ports::outbound::FolderStoragePort; use crate::application::ports::storage_ports::FileReadPort; +use crate::common::errors::Result; /** * Search service implementation for files and folders. - * + * * This service implements the advanced search functionality that allows * users to find files and folders based on various criteria * such as name, type, date and size. It also includes a cache to improve @@ -24,16 +24,16 @@ use crate::application::ports::storage_ports::FileReadPort; pub struct SearchService { /// Repository for file operations file_repository: Arc, - + /// Repository for folder operations folder_repository: Arc, - + /// Search results cache with expiration time search_cache: Arc>>, - + /// Cache validity duration in seconds cache_ttl: u64, - + /// Maximum cache size (number of stored results) max_cache_size: usize, } @@ -43,7 +43,7 @@ pub struct SearchService { struct SearchCacheKey { /// Serialized representation of the search criteria criteria_hash: String, - + /// User ID (to isolate searches between users) user_id: String, } @@ -52,7 +52,7 @@ struct SearchCacheKey { struct CachedSearchResult { /// Search results results: SearchResultsDto, - + /// Time when the cache entry was created timestamp: Instant, } @@ -60,7 +60,7 @@ struct CachedSearchResult { impl SearchService { /** * Creates a new instance of the search service. - * + * * @param file_repository Repository for file operations * @param folder_repository Repository for folder operations * @param cache_ttl Cache time-to-live in seconds (0 to disable) @@ -79,18 +79,18 @@ impl SearchService { cache_ttl, max_cache_size, }; - + // Start cache cleanup task if TTL > 0 if cache_ttl > 0 { Self::start_cache_cleanup_task(search_service.search_cache.clone(), cache_ttl); } - + search_service } - + /** * Starts an asynchronous task to clean up expired cache entries. - * + * * @param cache_ref Reference to the shared cache * @param ttl_seconds TTL in seconds */ @@ -101,21 +101,21 @@ impl SearchService { tokio::spawn(async move { let cleanup_interval = Duration::from_secs(ttl_seconds / 2); let ttl = Duration::from_secs(ttl_seconds); - + loop { time::sleep(cleanup_interval).await; - + // Acquire lock and clean up expired entries if let Ok(mut cache) = cache_ref.lock() { let now = Instant::now(); - + // Identify expired entries let expired_keys: Vec = cache .iter() .filter(|(_, result)| now.duration_since(result.timestamp) > ttl) .map(|(key, _)| key.clone()) .collect(); - + // Remove expired entries for key in expired_keys { cache.remove(&key); @@ -124,10 +124,10 @@ impl SearchService { } }); } - + /** * Creates a cache key from the search criteria. - * + * * @param criteria Search criteria * @param user_id User ID (to isolate cache between users) * @return Cache key @@ -135,16 +135,16 @@ impl SearchService { fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey { // Serialize criteria to generate a hash let criteria_str = serde_json::to_string(criteria).unwrap_or_default(); - + SearchCacheKey { criteria_hash: criteria_str, user_id: user_id.to_string(), } } - + /** * Attempts to retrieve results from the cache. - * + * * @param key Cache key * @return Optionally, the results if they exist and have not expired */ @@ -153,24 +153,25 @@ impl SearchService { if self.cache_ttl == 0 { return None; } - + if let Ok(cache) = self.search_cache.lock() - && let Some(cached_result) = cache.get(key) { - let now = Instant::now(); - let ttl = Duration::from_secs(self.cache_ttl); - - // Check if the entry has expired - if now.duration_since(cached_result.timestamp) < ttl { - return Some(cached_result.results.clone()); - } + && let Some(cached_result) = cache.get(key) + { + let now = Instant::now(); + let ttl = Duration::from_secs(self.cache_ttl); + + // Check if the entry has expired + if now.duration_since(cached_result.timestamp) < ttl { + return Some(cached_result.results.clone()); } - + } + None } - + /** * Stores results in the cache. - * + * * @param key Cache key * @param results Results to store */ @@ -179,45 +180,56 @@ impl SearchService { if self.cache_ttl == 0 { return; } - + if let Ok(mut cache) = self.search_cache.lock() { // If the cache is full, remove the oldest entry if cache.len() >= self.max_cache_size - && let Some((oldest_key, _)) = cache - .iter() - .min_by_key(|(_, result)| result.timestamp) { - let key_to_remove = oldest_key.clone(); - cache.remove(&key_to_remove); - } - + && let Some((oldest_key, _)) = + cache.iter().min_by_key(|(_, result)| result.timestamp) + { + let key_to_remove = oldest_key.clone(); + cache.remove(&key_to_remove); + } + // Store the new result - cache.insert(key, CachedSearchResult { - results, - timestamp: Instant::now(), - }); + cache.insert( + key, + CachedSearchResult { + results, + timestamp: Instant::now(), + }, + ); } } - + /** * Filters files according to the search criteria. - * + * * @param files List of files to filter * @param criteria Search criteria * @return Files that match the criteria */ fn filter_files(&self, files: Vec, criteria: &SearchCriteriaDto) -> Vec { - files.into_iter() + files + .into_iter() .filter(|file| { // Filter by name if let Some(name_query) = &criteria.name_contains - && !file.name.to_lowercase().contains(&name_query.to_lowercase()) { - return false; - } - + && !file + .name + .to_lowercase() + .contains(&name_query.to_lowercase()) + { + return false; + } + // Filter by file type (extension) if let Some(file_types) = &criteria.file_types { if let Some(extension) = file.name.split('.').next_back() { - if !file_types.iter().any(|ext| ext.eq_ignore_ascii_case(extension)) { + if !file_types + .iter() + .any(|ext| ext.eq_ignore_ascii_case(extension)) + { return false; } } else { @@ -225,91 +237,110 @@ impl SearchService { return false; } } - + // Filter by creation date if let Some(created_after) = criteria.created_after - && file.created_at < created_after { - return false; - } - + && file.created_at < created_after + { + return false; + } + if let Some(created_before) = criteria.created_before - && file.created_at > created_before { - return false; - } - + && file.created_at > created_before + { + return false; + } + // Filter by modification date if let Some(modified_after) = criteria.modified_after - && file.modified_at < modified_after { - return false; - } - + && file.modified_at < modified_after + { + return false; + } + if let Some(modified_before) = criteria.modified_before - && file.modified_at > modified_before { - return false; - } - + && file.modified_at > modified_before + { + return false; + } + // Filter by size if let Some(min_size) = criteria.min_size - && file.size < min_size { - return false; - } - + && file.size < min_size + { + return false; + } + if let Some(max_size) = criteria.max_size - && file.size > max_size { - return false; - } - + && file.size > max_size + { + return false; + } + true }) .collect() } - + /** * Filters folders according to the search criteria. - * + * * @param folders List of folders to filter * @param criteria Search criteria * @return Folders that match the criteria */ - fn filter_folders(&self, folders: Vec, criteria: &SearchCriteriaDto) -> Vec { - folders.into_iter() + fn filter_folders( + &self, + folders: Vec, + criteria: &SearchCriteriaDto, + ) -> Vec { + folders + .into_iter() .filter(|folder| { // Filter by name if let Some(name_query) = &criteria.name_contains - && !folder.name.to_lowercase().contains(&name_query.to_lowercase()) { - return false; - } - + && !folder + .name + .to_lowercase() + .contains(&name_query.to_lowercase()) + { + return false; + } + // Filter by creation date if let Some(created_after) = criteria.created_after - && folder.created_at < created_after { - return false; - } - + && folder.created_at < created_after + { + return false; + } + if let Some(created_before) = criteria.created_before - && folder.created_at > created_before { - return false; - } - + && folder.created_at > created_before + { + return false; + } + // Filter by modification date if let Some(modified_after) = criteria.modified_after - && folder.modified_at < modified_after { - return false; - } - + && folder.modified_at < modified_after + { + return false; + } + if let Some(modified_before) = criteria.modified_before - && folder.modified_at > modified_before { - return false; - } - + && folder.modified_at > modified_before + { + return false; + } + true }) .collect() } - + /** * Implementation of recursive search through folders. - * + * * @param current_folder_id ID of the current folder * @param criteria Search criteria * @param found_files Files found so far @@ -323,43 +354,39 @@ impl SearchService { found_folders: &mut Vec, ) -> Result<()> { Box::pin(async move { - // List files in the current folder - let files = self.file_repository.list_files(current_folder_id).await?; - - // Filter files according to criteria and add them to the results - let filtered_files = self.filter_files( - files.into_iter().map(FileDto::from).collect(), - criteria - ); - found_files.extend(filtered_files); - - // If the search is recursive, process subfolders - if criteria.recursive { - // List subfolders - let folders = self.folder_repository.list_folders(current_folder_id).await?; - - // Filter folders according to criteria and add them to the results - let filtered_folders: Vec = self.filter_folders( - folders.into_iter().map(FolderDto::from).collect(), - criteria - ); - - // Add filtered folders to the results - found_folders.extend(filtered_folders.iter().cloned()); - - // Search recursively in each subfolder - for folder in filtered_folders { - self.search_recursive( - Some(&folder.id), - criteria, - found_files, - found_folders, - ).await?; + // List files in the current folder + let files = self.file_repository.list_files(current_folder_id).await?; + + // Filter files according to criteria and add them to the results + let filtered_files = + self.filter_files(files.into_iter().map(FileDto::from).collect(), criteria); + found_files.extend(filtered_files); + + // If the search is recursive, process subfolders + if criteria.recursive { + // List subfolders + let folders = self + .folder_repository + .list_folders(current_folder_id) + .await?; + + // Filter folders according to criteria and add them to the results + let filtered_folders: Vec = self + .filter_folders(folders.into_iter().map(FolderDto::from).collect(), criteria); + + // Add filtered folders to the results + found_folders.extend(filtered_folders.iter().cloned()); + + // Search recursively in each subfolder + for folder in filtered_folders { + self.search_recursive(Some(&folder.id), criteria, found_files, found_folders) + .await?; + } } - } - - Ok(()) - }).await + + Ok(()) + }) + .await } } @@ -367,7 +394,7 @@ impl SearchService { impl SearchUseCase for SearchService { /** * Performs a search based on the specified criteria. - * + * * @param criteria Search criteria * @return Search results */ @@ -375,36 +402,37 @@ impl SearchUseCase for SearchService { // TODO: Get user ID from the authentication context let user_id = "default-user"; let cache_key = self.create_cache_key(&criteria, user_id); - + // Try to get results from the cache if let Some(cached_results) = self.get_from_cache(&cache_key) { return Ok(cached_results); } - + // Initialize collections for results let mut found_files: Vec = Vec::new(); let mut found_folders: Vec = Vec::new(); - + // Perform search in the specified folder or at the root self.search_recursive( criteria.folder_id.as_deref(), &criteria, &mut found_files, &mut found_folders, - ).await?; - + ) + .await?; + // Apply pagination let total_count = found_files.len() + found_folders.len(); - + // Sort by relevance or date according to criteria // By default, sort by modification date (most recent first) found_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); found_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); - + // Apply limit and offset for pagination let start_idx = criteria.offset.min(total_count); let end_idx = (criteria.offset + criteria.limit).min(total_count); - + let paginated_items: Vec<(bool, usize)> = (start_idx..end_idx) .map(|i| { if i < found_folders.len() { @@ -414,11 +442,11 @@ impl SearchUseCase for SearchService { } }) .collect(); - + // Extract paginated items let mut paginated_folders = Vec::new(); let mut paginated_files = Vec::new(); - + for (is_folder, idx) in paginated_items { if is_folder { if idx < found_folders.len() { @@ -428,7 +456,7 @@ impl SearchUseCase for SearchService { paginated_files.push(found_files[idx].clone()); } } - + // Create results object let search_results = SearchResultsDto::new( paginated_files, @@ -437,16 +465,16 @@ impl SearchUseCase for SearchService { criteria.offset, Some(total_count), ); - + // Store in cache self.store_in_cache(cache_key, search_results.clone()); - + Ok(search_results) } - + /** * Clears the search results cache. - * + * * @return Result indicating success */ async fn clear_search_cache(&self) -> Result<()> { @@ -462,18 +490,18 @@ impl SearchService { /// Creates a stub version of the service for testing pub fn new_stub() -> impl SearchUseCase { struct SearchServiceStub; - + #[async_trait] impl SearchUseCase for SearchServiceStub { async fn search(&self, _criteria: SearchCriteriaDto) -> Result { Ok(SearchResultsDto::empty()) } - + async fn clear_search_cache(&self) -> Result<()> { Ok(()) } } - + SearchServiceStub } -} \ No newline at end of file +} diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 3bfd3864..c5f91bf1 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -47,7 +47,9 @@ impl From for DomainError { ShareServiceError::ItemNotFound(s) => DomainError::not_found("Item", s), ShareServiceError::AccessDenied(s) => DomainError::access_denied("Share", s), ShareServiceError::InvalidPassword(s) => DomainError::access_denied("Share", s), - ShareServiceError::Expired => DomainError::access_denied("Share", "Share has expired".to_string()), + ShareServiceError::Expired => { + DomainError::access_denied("Share", "Share has expired".to_string()) + } ShareServiceError::Repository(s) => DomainError::internal_error("Share", s), ShareServiceError::InvalidItemType(s) => DomainError::validation_error(s), ShareServiceError::Validation(s) => DomainError::validation_error(s), @@ -91,13 +93,23 @@ impl ShareService { self.file_repository .get_file(item_id) // Using the correct method from the FileStoragePort trait .await - .map_err(|_| ShareServiceError::ItemNotFound(format!("File with ID {} not found", item_id)))?; + .map_err(|_| { + ShareServiceError::ItemNotFound(format!( + "File with ID {} not found", + item_id + )) + })?; } ShareItemType::Folder => { self.folder_repository .get_folder(item_id) // Using the correct method from the FolderStoragePort trait .await - .map_err(|_| ShareServiceError::ItemNotFound(format!("Folder with ID {} not found", item_id)))?; + .map_err(|_| { + ShareServiceError::ItemNotFound(format!( + "Folder with ID {} not found", + item_id + )) + })?; } } Ok(()) @@ -105,13 +117,13 @@ impl ShareService { /// Password hash using Argon2id (resistant to timing attacks and GPU attacks) fn hash_password(&self, password: &str) -> String { - use argon2::{Argon2, PasswordHasher}; use argon2::password_hash::SaltString; + use argon2::{Argon2, PasswordHasher}; use rand_core::OsRng; - + let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - + argon2 .hash_password(password.as_bytes(), &salt) .expect("Failed to hash share password") @@ -167,7 +179,9 @@ impl ShareUseCase for ShareService { .share_repository .find_share_by_id(id) .await - .map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?; + .map_err(|e| { + ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)) + })?; // Check if it has expired if share.is_expired() { @@ -184,7 +198,9 @@ impl ShareUseCase for ShareService { .share_repository .find_share_by_token(token) .await - .map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?; + .map_err(|e| { + ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)) + })?; // Check if it has expired if share.is_expired() { @@ -229,7 +245,9 @@ impl ShareUseCase for ShareService { .share_repository .find_share_by_id(id) .await - .map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?; + .map_err(|e| { + ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)) + })?; // Update permissions if provided if let Some(permissions_dto) = dto.permissions { @@ -264,7 +282,10 @@ impl ShareUseCase for ShareService { .map_err(|e| ShareServiceError::Repository(e.to_string()))?; // Convert the entity to DTO for the response - Ok(ShareDto::from_entity(&updated_share, &self.config.base_url())) + Ok(ShareDto::from_entity( + &updated_share, + &self.config.base_url(), + )) } async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError> { @@ -300,12 +321,7 @@ impl ShareUseCase for ShareService { .collect(); // Create the paginated result - let paginated = PaginatedResponseDto::new( - share_dtos, - page, - per_page, - total - ); + let paginated = PaginatedResponseDto::new(share_dtos, page, per_page, total); Ok(paginated) } @@ -320,7 +336,9 @@ impl ShareUseCase for ShareService { .share_repository .find_share_by_token(token) .await - .map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?; + .map_err(|e| { + ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)) + })?; // Check if it has expired if share.is_expired() { @@ -329,9 +347,7 @@ impl ShareUseCase for ShareService { // Verify the password using the infrastructure port match share.password_hash() { - Some(hash) => { - self.password_hasher.verify_password(password, hash) - } + Some(hash) => self.password_hasher.verify_password(password, hash), None => Ok(true), // No password required } } @@ -342,7 +358,9 @@ impl ShareUseCase for ShareService { .share_repository .find_share_by_token(token) .await - .map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?; + .map_err(|e| { + ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)) + })?; // Check if it has expired if share.is_expired() { @@ -365,9 +383,9 @@ impl ShareUseCase for ShareService { #[cfg(test)] mod tests { use super::*; - use crate::application::ports::share_ports::ShareStoragePort; - use crate::application::ports::auth_ports::PasswordHasherPort; use crate::application::dtos::share_dto::SharePermissionsDto; + use crate::application::ports::auth_ports::PasswordHasherPort; + use crate::application::ports::share_ports::ShareStoragePort; use crate::common::config::AppConfig; use crate::domain::repositories::folder_repository::FolderRepository; use async_trait::async_trait; @@ -391,12 +409,17 @@ mod tests { #[async_trait] impl FileReadPort for MockFileRepository { - async fn get_file(&self, id: &str) -> Result { + async fn get_file( + &self, + id: &str, + ) -> Result { if id == "test_file_id" { let file = crate::domain::entities::file::File::new( id.to_string(), "test.txt".to_string(), - crate::domain::services::path_service::StoragePath::from_string("/path/to/test.txt"), + crate::domain::services::path_service::StoragePath::from_string( + "/path/to/test.txt", + ), 123, "text/plain".to_string(), None, @@ -407,11 +430,14 @@ mod tests { Err(DomainError::not_found("File", id)) } } - - async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { + + async fn list_files( + &self, + _folder_id: Option<&str>, + ) -> Result, DomainError> { unimplemented!() } - + async fn get_file_content(&self, _id: &str) -> Result, DomainError> { unimplemented!() } @@ -419,7 +445,10 @@ mod tests { async fn get_file_stream( &self, _id: &str, - ) -> Result> + Send>, DomainError> { + ) -> Result< + Box> + Send>, + DomainError, + > { unimplemented!() } @@ -428,7 +457,10 @@ mod tests { _id: &str, _start: u64, _end: Option, - ) -> Result> + Send>, DomainError> { + ) -> Result< + Box> + Send>, + DomainError, + > { unimplemented!() } @@ -436,7 +468,10 @@ mod tests { unimplemented!() } - async fn get_file_path(&self, _id: &str) -> Result { + async fn get_file_path( + &self, + _id: &str, + ) -> Result { unimplemented!() } @@ -447,16 +482,25 @@ mod tests { #[async_trait] impl FolderRepository for MockFolderRepository { - async fn create_folder(&self, _name: String, _parent_id: Option) -> Result { + async fn create_folder( + &self, + _name: String, + _parent_id: Option, + ) -> Result { unimplemented!() } - async fn get_folder(&self, id: &str) -> Result { + async fn get_folder( + &self, + id: &str, + ) -> Result { if id == "test_folder_id" { let folder = crate::domain::entities::folder::Folder::new( id.to_string(), "test".to_string(), - crate::domain::services::path_service::StoragePath::from_string("/path/to/test"), + crate::domain::services::path_service::StoragePath::from_string( + "/path/to/test", + ), None, ) .unwrap(); @@ -466,35 +510,62 @@ mod tests { } } - async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { - unimplemented!() - } - - async fn list_folders(&self, _parent_id: Option<&str>) -> Result, DomainError> { + async fn get_folder_by_path( + &self, + _storage_path: &crate::domain::services::path_service::StoragePath, + ) -> Result { unimplemented!() } - async fn list_folders_paginated(&self, _parent_id: Option<&str>, _offset: usize, _limit: usize, _include_total: bool) -> Result<(Vec, Option), DomainError> { + async fn list_folders( + &self, + _parent_id: Option<&str>, + ) -> Result, DomainError> { unimplemented!() } - async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { + async fn list_folders_paginated( + &self, + _parent_id: Option<&str>, + _offset: usize, + _limit: usize, + _include_total: bool, + ) -> Result<(Vec, Option), DomainError> + { unimplemented!() } - async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result { + async fn rename_folder( + &self, + _id: &str, + _new_name: String, + ) -> Result { unimplemented!() } - + + async fn move_folder( + &self, + _id: &str, + _new_parent_id: Option<&str>, + ) -> Result { + unimplemented!() + } + async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { unimplemented!() } - async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result { + async fn folder_exists( + &self, + _storage_path: &crate::domain::services::path_service::StoragePath, + ) -> Result { unimplemented!() } - async fn get_folder_path(&self, _id: &str) -> Result { + async fn get_folder_path( + &self, + _id: &str, + ) -> Result { unimplemented!() } @@ -502,7 +573,11 @@ mod tests { unimplemented!() } - async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> { + async fn restore_from_trash( + &self, + _folder_id: &str, + _original_path: &str, + ) -> Result<(), DomainError> { unimplemented!() } @@ -530,91 +605,103 @@ mod tests { async fn save_share(&self, share: &Share) -> Result { let mut shares = self.shares.lock().unwrap(); let mut tokens = self.tokens.lock().unwrap(); - + shares.insert(share.id().to_string(), share.clone()); tokens.insert(share.token().to_string(), share.id().to_string()); - + Ok(share.clone()) } - + async fn find_share_by_id(&self, id: &str) -> Result { let shares = self.shares.lock().unwrap(); - - shares.get(id) + + shares + .get(id) .cloned() .ok_or_else(|| DomainError::not_found("Share", id)) } - + async fn find_share_by_token(&self, token: &str) -> Result { let tokens = self.tokens.lock().unwrap(); let shares = self.shares.lock().unwrap(); - - let id = tokens.get(token) + + let id = tokens + .get(token) .ok_or_else(|| DomainError::not_found("Share", token))?; - - shares.get(id) + + shares + .get(id) .cloned() .ok_or_else(|| DomainError::not_found("Share", id.as_str())) } - - async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result, DomainError> { + + async fn find_shares_by_item( + &self, + item_id: &str, + item_type: &ShareItemType, + ) -> Result, DomainError> { let shares = self.shares.lock().unwrap(); - + let type_str = item_type.to_string(); - let result: Vec = shares.values() + let result: Vec = shares + .values() .filter(|s| s.item_id() == item_id && s.item_type().to_string() == type_str) .cloned() .collect(); - + Ok(result) } - + async fn update_share(&self, share: &Share) -> Result { let mut shares = self.shares.lock().unwrap(); - + let id_str = share.id().to_string(); if !shares.contains_key(&id_str) { return Err(DomainError::not_found("Share", &id_str)); } - + shares.insert(id_str, share.clone()); - + Ok(share.clone()) } - + async fn delete_share(&self, id: &str) -> Result<(), DomainError> { let mut shares = self.shares.lock().unwrap(); let mut tokens = self.tokens.lock().unwrap(); - + // Find the share to get the token - let share = shares.get(id) + let share = shares + .get(id) .ok_or_else(|| DomainError::not_found("Share", id))?; - + // Remove token mapping tokens.remove(share.token()); - + // Remove the share shares.remove(id); - + Ok(()) } - - async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec, usize), DomainError> { + + async fn find_shares_by_user( + &self, + user_id: &str, + offset: usize, + limit: usize, + ) -> Result<(Vec, usize), DomainError> { let shares = self.shares.lock().unwrap(); - - let user_shares: Vec = shares.values() + + let user_shares: Vec = shares + .values() .filter(|s| s.created_by() == user_id) .cloned() .collect(); - + let total = user_shares.len(); - + // Apply pagination - let paginated = user_shares.into_iter() - .skip(offset) - .take(limit) - .collect(); - + let paginated = user_shares.into_iter().skip(offset).take(limit).collect(); + Ok((paginated, total)) } } @@ -622,14 +709,15 @@ mod tests { #[tokio::test] async fn test_create_shared_link() { let config = Arc::new(AppConfig::default()); - + let share_repo = Arc::new(MockShareRepository::new()); let file_repo = Arc::new(MockFileRepository); let folder_repo = Arc::new(MockFolderRepository); let password_hasher = Arc::new(MockPasswordHasher); - - let service = ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher); - + + let service = + ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher); + // Test creating a file share let dto = CreateShareDto { item_id: "test_file_id".to_string(), @@ -642,14 +730,14 @@ mod tests { reshare: false, }), }; - + let result = service.create_shared_link("user123", dto).await; assert!(result.is_ok()); - + let share_dto = result.unwrap(); assert_eq!(share_dto.item_id, "test_file_id"); assert_eq!(share_dto.item_type, "file"); assert!(share_dto.has_password); assert!(share_dto.url.starts_with("http://127.0.0.1:8085/s/")); } -} \ No newline at end of file +} diff --git a/src/application/services/storage_mediator.rs b/src/application/services/storage_mediator.rs index 34751c94..d2978224 100644 --- a/src/application/services/storage_mediator.rs +++ b/src/application/services/storage_mediator.rs @@ -1,30 +1,30 @@ +use async_trait::async_trait; use std::path::{Path, PathBuf}; use std::sync::Arc; -use async_trait::async_trait; use thiserror::Error; +use crate::application::ports::outbound::{FolderStoragePort, IdMappingPort, StoragePort}; use crate::domain::entities::folder::Folder; use crate::domain::services::path_service::StoragePath; -use crate::application::ports::outbound::{IdMappingPort, StoragePort, FolderStoragePort}; /// Storage mediator specific errors #[derive(Debug, Error)] pub enum StorageMediatorError { #[error("Entity not found: {0}")] NotFound(String), - + #[error("Entity already exists: {0}")] AlreadyExists(String), - + #[error("Invalid path: {0}")] InvalidPath(String), - + #[error("Access error: {0}")] AccessError(String), - + #[error("Internal error: {0}")] InternalError(String), - + #[error("Domain error: {0}")] DomainError(#[from] crate::common::errors::DomainError), } @@ -37,36 +37,45 @@ pub type StorageMediatorResult = Result; pub trait StorageMediator: Send + Sync + 'static { /// Gets the path of a folder by its ID async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult; - + /// Gets the domain path of a folder by its ID async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult; - + /// Gets all details of a folder by its ID async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult; - + /// Checks if a file exists at a specific path async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult; - + /// Checks if a file exists at a specific domain path - async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult; - + async fn file_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult; + /// Checks if a folder exists at a specific path async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult; - + /// Checks if a folder exists at a specific domain path - async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult; - + async fn folder_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult; + /// Resolves a relative path to absolute (legacy) fn resolve_path(&self, relative_path: &Path) -> PathBuf; - + /// Resolves a domain path to an absolute physical path fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf; - + /// Creates a directory if it does not exist (legacy) async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()>; - + /// Creates a directory if it does not exist - async fn ensure_storage_directory(&self, storage_path: &StoragePath) -> StorageMediatorResult<()>; + async fn ensure_storage_directory( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult<()>; } /// Concrete implementation of the storage mediator @@ -77,10 +86,18 @@ pub struct FileSystemStorageMediator { } impl FileSystemStorageMediator { - pub fn new(folder_storage_port: Arc, path_service: Arc, id_mapping: Arc) -> Self { - Self { folder_storage_port, path_service, id_mapping } + pub fn new( + folder_storage_port: Arc, + path_service: Arc, + id_mapping: Arc, + ) -> Self { + Self { + folder_storage_port, + path_service, + id_mapping, + } } - + /// Creates a stub implementation for initialization bootstrapping pub fn new_stub() -> StubStorageMediator { StubStorageMediator::new() @@ -109,46 +126,60 @@ impl StorageMediator for StubStorageMediator { // Return a stub path Ok(PathBuf::from("/tmp")) } - - async fn get_folder_storage_path(&self, _folder_id: &str) -> StorageMediatorResult { + + async fn get_folder_storage_path( + &self, + _folder_id: &str, + ) -> StorageMediatorResult { // Return a stub storage path Ok(StoragePath::root()) } - + async fn get_folder(&self, _folder_id: &str) -> StorageMediatorResult { // This is a stub that should never be called during initialization - Err(StorageMediatorError::NotFound("Stub not implemented".to_string())) + Err(StorageMediatorError::NotFound( + "Stub not implemented".to_string(), + )) } - + async fn file_exists_at_path(&self, _path: &Path) -> StorageMediatorResult { Ok(false) } - - async fn file_exists_at_storage_path(&self, _storage_path: &StoragePath) -> StorageMediatorResult { + + async fn file_exists_at_storage_path( + &self, + _storage_path: &StoragePath, + ) -> StorageMediatorResult { Ok(false) } - + async fn folder_exists_at_path(&self, _path: &Path) -> StorageMediatorResult { Ok(false) } - - async fn folder_exists_at_storage_path(&self, _storage_path: &StoragePath) -> StorageMediatorResult { + + async fn folder_exists_at_storage_path( + &self, + _storage_path: &StoragePath, + ) -> StorageMediatorResult { Ok(false) } - + fn resolve_path(&self, _relative_path: &Path) -> PathBuf { PathBuf::from("/tmp") } - + fn resolve_storage_path(&self, _storage_path: &StoragePath) -> PathBuf { PathBuf::from("/tmp") } - + async fn ensure_directory(&self, _path: &Path) -> StorageMediatorResult<()> { Ok(()) } - - async fn ensure_storage_directory(&self, _storage_path: &StoragePath) -> StorageMediatorResult<()> { + + async fn ensure_storage_directory( + &self, + _storage_path: &StoragePath, + ) -> StorageMediatorResult<()> { Ok(()) } } @@ -156,112 +187,140 @@ impl StorageMediator for StubStorageMediator { #[async_trait] impl StorageMediator for FileSystemStorageMediator { async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult { - let folder = self.folder_storage_port.get_folder(folder_id).await + let folder = self + .folder_storage_port + .get_folder(folder_id) + .await .map_err(StorageMediatorError::from)?; - + // Need to get the path from folder ID - let storage_path = self.id_mapping.get_path_by_id(folder.id()).await + let storage_path = self + .id_mapping + .get_path_by_id(folder.id()) + .await .map_err(StorageMediatorError::from)?; - + // Convert StoragePath to PathBuf let path_buf = self.path_service.resolve_path(&storage_path); Ok(path_buf) } - + async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult { - let folder = self.folder_storage_port.get_folder(folder_id).await + let folder = self + .folder_storage_port + .get_folder(folder_id) + .await .map_err(StorageMediatorError::from)?; - + // Get path by folder ID - will already be a StoragePath - let storage_path = self.id_mapping.get_path_by_id(folder.id()).await + let storage_path = self + .id_mapping + .get_path_by_id(folder.id()) + .await .map_err(StorageMediatorError::from)?; - + Ok(storage_path) } - + async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult { - let folder = self.folder_storage_port.get_folder(folder_id).await + let folder = self + .folder_storage_port + .get_folder(folder_id) + .await .map_err(StorageMediatorError::from)?; - + Ok(folder) } - + async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult { let abs_path = self.resolve_path(path); - + // Check if it exists as a file (not as a directory) let exists = abs_path.exists() && abs_path.is_file(); - + Ok(exists) } - - async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult { + + async fn file_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult { let abs_path = self.resolve_storage_path(storage_path); - + // Check if it exists as a file (not as a directory) let exists = abs_path.exists() && abs_path.is_file(); - + Ok(exists) } - + async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult { let abs_path = self.resolve_path(path); - + // Check if it exists as a directory let exists = abs_path.exists() && abs_path.is_dir(); - + Ok(exists) } - - async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult { + + async fn folder_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult { let abs_path = self.resolve_storage_path(storage_path); - + // Check if it exists as a directory let exists = abs_path.exists() && abs_path.is_dir(); - + Ok(exists) } - + fn resolve_path(&self, relative_path: &Path) -> PathBuf { // Legacy method using PathBuf let path_str = relative_path.to_string_lossy().to_string(); let storage_path = StoragePath::from_string(&path_str); self.path_service.resolve_path(&storage_path) } - + fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { self.path_service.resolve_path(storage_path) } - + async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()> { let abs_path = self.resolve_path(path); - + // Create directories if they don't exist if !abs_path.exists() { - tokio::fs::create_dir_all(&abs_path).await - .map_err(|e| StorageMediatorError::AccessError(format!("Could not create directory: {}", e)))?; + tokio::fs::create_dir_all(&abs_path).await.map_err(|e| { + StorageMediatorError::AccessError(format!("Could not create directory: {}", e)) + })?; } else if !abs_path.is_dir() { - return Err(StorageMediatorError::InvalidPath( - format!("Path exists but is not a directory: {}", abs_path.display()) - )); + return Err(StorageMediatorError::InvalidPath(format!( + "Path exists but is not a directory: {}", + abs_path.display() + ))); } - + Ok(()) } - - async fn ensure_storage_directory(&self, storage_path: &StoragePath) -> StorageMediatorResult<()> { + + async fn ensure_storage_directory( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult<()> { let abs_path = self.resolve_storage_path(storage_path); - + // Create directories if they don't exist if !abs_path.exists() { - tokio::fs::create_dir_all(&abs_path).await - .map_err(|e| StorageMediatorError::AccessError(format!("Could not create directory: {}", e)))?; + tokio::fs::create_dir_all(&abs_path).await.map_err(|e| { + StorageMediatorError::AccessError(format!("Could not create directory: {}", e)) + })?; } else if !abs_path.is_dir() { - return Err(StorageMediatorError::InvalidPath( - format!("Path exists but is not a directory: {}", abs_path.display()) - )); + return Err(StorageMediatorError::InvalidPath(format!( + "Path exists but is not a directory: {}", + abs_path.display() + ))); } - + Ok(()) } -} \ No newline at end of file +} diff --git a/src/application/services/storage_usage_service.rs b/src/application/services/storage_usage_service.rs index dde61a94..0aeee068 100644 --- a/src/application/services/storage_usage_service.rs +++ b/src/application/services/storage_usage_service.rs @@ -1,14 +1,14 @@ -use std::sync::Arc; -use async_trait::async_trait; -use tokio::task; -use crate::common::errors::DomainError; use crate::application::ports::auth_ports::UserStoragePort; use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; -use tracing::{info, error, debug}; +use crate::common::errors::DomainError; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::task; +use tracing::{debug, error, info}; /** * Service for managing and updating user storage usage statistics. - * + * * This service is responsible for calculating how much storage each user * is using and updating this information in the user records. */ @@ -28,51 +28,63 @@ impl StorageUsageService { user_repository, } } - + /// Calculates and updates storage usage for a specific user pub async fn update_user_storage_usage(&self, user_id: &str) -> Result { info!("Updating storage usage for user: {}", user_id); - + // Get user's home folder pattern let user = self.user_repository.get_user_by_id(user_id).await?; let username = user.username(); - + // Calculate storage usage for this user let total_usage = self.calculate_user_storage_usage(username).await?; - + // Update the user's storage usage in the database - self.user_repository.update_storage_usage(user_id, total_usage).await?; - - info!("Updated storage usage for user {} to {} bytes", user_id, total_usage); - + self.user_repository + .update_storage_usage(user_id, total_usage) + .await?; + + info!( + "Updated storage usage for user {} to {} bytes", + user_id, total_usage + ); + Ok(total_usage) } - + /// Calculates a user's storage usage based on their home folder async fn calculate_user_storage_usage(&self, username: &str) -> Result { debug!("Calculating storage for user: {}", username); // First, try to find the user's home folder // List all folders to locate the user's folder - let all_folders = self.file_repository.list_files(None).await + let all_folders = self + .file_repository + .list_files(None) + .await .map_err(|e| DomainError::internal_error("File repository", e.to_string()))?; - + // Find the user's home folder (named "My Folder - {username}") let home_folder_name = format!("My Folder - {}", username); debug!("Looking for home folder: {}", home_folder_name); - + let mut total_usage: i64 = 0; let mut home_folder_id = None; - + // Find the home folder ID for folder in &all_folders { if folder.name() == home_folder_name { home_folder_id = Some(folder.id().to_string()); - debug!("Found home folder for user {}: ID={}", username, folder.id()); + debug!( + "Found home folder for user {}: ID={}", + username, + folder.id() + ); break; } } - + // If we found the home folder, calculate total size if let Some(folder_id) = home_folder_id { // Calculate recursively @@ -81,10 +93,10 @@ impl StorageUsageService { // If no home folder found, just return 0 debug!("No home folder found for user: {}", username); } - + Ok(total_usage) } - + /// Recursively calculates the size of a folder and all its contents async fn calculate_folder_size(&self, folder_id: &str) -> Result { // Implementation with explicit boxing to handle recursion in async functions @@ -93,11 +105,13 @@ impl StorageUsageService { folder_id: &str, ) -> Result { let mut total_size: i64 = 0; - + // Get files directly in this folder - let files = repo.list_files(Some(folder_id)).await + let files = repo + .list_files(Some(folder_id)) + .await .map_err(|e| DomainError::internal_error("File repository", e.to_string()))?; - + // Sum the size of all files for file in &files { // Skip subdirectories at this level - we'll process them separately @@ -105,16 +119,20 @@ impl StorageUsageService { // Recursively calculate subfolder size with explicit boxing let subfolder_id = file.id().to_string(); // Create owned copy let repo_clone = repo.clone(); // Clone the repository - + // Use Box::pin to handle recursive async call - let subfolder_size_future = Box::pin(inner_calculate_size(repo_clone, &subfolder_id)); - + let subfolder_size_future = + Box::pin(inner_calculate_size(repo_clone, &subfolder_id)); + match subfolder_size_future.await { Ok(size) => { total_size += size; - }, + } Err(e) => { - error!("Error calculating size for subfolder {}: {}", subfolder_id, e); + error!( + "Error calculating size for subfolder {}: {}", + subfolder_id, e + ); // Continue with other folders even if one fails } } @@ -123,10 +141,10 @@ impl StorageUsageService { total_size += file.size() as i64; } } - + Ok(total_size) } - + // Start the calculation with a clone of our repository reference let repo_clone = Arc::clone(&self.file_repository); inner_calculate_size(repo_clone, folder_id).await @@ -148,37 +166,40 @@ impl StorageUsagePort for StorageUsageService { // Get the list of all users let users = self.user_repository.list_users(1000, 0).await?; - + let mut update_tasks = Vec::new(); - + // Process users in parallel for user in users { let user_id = user.id().to_string(); let service_clone = self.clone(); - + // Spawn a background task for each user let task = task::spawn(async move { match service_clone.update_user_storage_usage(&user_id).await { Ok(usage) => { - debug!("Updated storage usage for user {}: {} bytes", user_id, usage); + debug!( + "Updated storage usage for user {}: {} bytes", + user_id, usage + ); Ok(()) - }, + } Err(e) => { error!("Failed to update storage for user {}: {}", user_id, e); Err(e) } } }); - + update_tasks.push(task); } - + // Wait for all tasks to complete for task in update_tasks { // We don't propagate errors from individual users to avoid failing the entire batch let _ = task.await; } - + info!("Completed batch update of all users' storage usage"); Ok(()) } @@ -192,4 +213,4 @@ impl Clone for StorageUsageService { user_repository: Arc::clone(&self.user_repository), } } -} \ No newline at end of file +} diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index ae54278c..d0f7c851 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -1,24 +1,24 @@ -use std::sync::Arc; use async_trait::async_trait; -use uuid::Uuid; +use std::sync::Arc; use tracing::{debug, error, info, instrument}; +use uuid::Uuid; use crate::application::dtos::trash_dto::TrashedItemDto; -use crate::application::ports::trash_ports::TrashUseCase; -use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::outbound::FolderStoragePort; -use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::ports::trash_ports::TrashUseCase; +use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::trash_repository::TrashRepository; /** * Application service for trash operations. - * + * * The TrashService implements the trash management functionality in the application layer, * handling movement of files and folders to trash, restoration from trash, and permanent * deletion. It orchestrates interactions between the domain entities and infrastructure * repositories while enforcing business rules like retention policies. - * + * * This service follows the Clean Architecture pattern by: * - Depending on application ports rather than domain/infrastructure traits * - Orchestrating domain operations without containing domain logic @@ -27,16 +27,16 @@ use crate::domain::repositories::trash_repository::TrashRepository; pub struct TrashService { /// Repository for trash-specific operations like listing and retrieving trashed items trash_repository: Arc, - + /// Port for file read operations (get file metadata) file_read_port: Arc, - + /// Port for file write operations (trash, restore, delete) file_write_port: Arc, - + /// Port for folder operations (get folder, trash, restore, delete) folder_storage_port: Arc, - + /// Number of days items should be kept in trash before automatic cleanup retention_days: u32, } @@ -62,7 +62,7 @@ impl TrashService { fn to_dto(&self, item: TrashedItem) -> TrashedItemDto { // Calculate days_until_deletion before moving item fields let days_until_deletion = item.days_until_deletion(); - + TrashedItemDto { id: item.id().to_string(), original_id: item.original_id().to_string(), @@ -86,12 +86,18 @@ impl TrashService { let user_uuid = Uuid::parse_str(user_id) .map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?; - match self.trash_repository.get_trash_item(&item_uuid, &user_uuid).await? { + match self + .trash_repository + .get_trash_item(&item_uuid, &user_uuid) + .await? + { Some(item) => { if item.user_id() != user_uuid { error!( "User {} attempted to access trash item {} owned by {}", - user_id, item_id, item.user_id() + user_id, + item_id, + item.user_id() ); return Err(DomainError::access_denied( "TrashItem", @@ -117,77 +123,84 @@ impl TrashUseCase for TrashService { #[instrument(skip(self))] async fn get_trash_items(&self, user_id: &str) -> Result> { debug!("Getting trash items for user: {}", user_id); - + let user_uuid = Uuid::parse_str(user_id) .map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?; - + let items = self.trash_repository.get_trash_items(&user_uuid).await?; - - let dtos = items.into_iter() - .map(|item| self.to_dto(item)) - .collect(); - + + let dtos = items.into_iter().map(|item| self.to_dto(item)).collect(); + Ok(dtos) } #[instrument(skip(self))] async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: &str) -> Result<()> { - info!("Moving to trash: type={}, id={}, user={}", item_type, item_id, user_id); + info!( + "Moving to trash: type={}, id={}, user={}", + item_type, item_id, user_id + ); debug!("User UUID validation: {}", user_id); - + // Note: We do NOT call validate_user_ownership here because the item // is not yet in the trash. Ownership validation is only for operations // on already-trashed items (restore, delete_permanently). - + // Parse UUIDs with detailed error handling debug!("Validating item UUID: {}", item_id); let item_uuid = match Uuid::parse_str(item_id) { Ok(uuid) => { debug!("Valid item UUID: {}", uuid); uuid - }, + } Err(e) => { error!("Invalid item UUID: {} - Error: {}", item_id, e); - return Err(DomainError::validation_error(format!("Invalid item ID: {}", e))); + return Err(DomainError::validation_error(format!( + "Invalid item ID: {}", + e + ))); } }; - + debug!("Validating user UUID: {}", user_id); let user_uuid = match Uuid::parse_str(user_id) { Ok(uuid) => { debug!("Valid user UUID: {}", uuid); uuid - }, + } Err(e) => { error!("Invalid user UUID: {} - Error: {}", user_id, e); - return Err(DomainError::validation_error(format!("Invalid user ID: {}", e))); + return Err(DomainError::validation_error(format!( + "Invalid user ID: {}", + e + ))); } }; - + match item_type { "file" => { info!("Processing file to move to trash: {}", item_id); - + // Get the file to verify it exists and capture its data debug!("Getting file data: {}", item_id); let file = match self.file_read_port.get_file(item_id).await { Ok(file) => { debug!("File found: {} ({})", file.name(), item_id); file - }, + } Err(e) => { error!("Error getting file: {} - {}", item_id, e); return Err(DomainError::new( ErrorKind::NotFound, "File", - format!("Error retrieving file {}: {}", item_id, e) + format!("Error retrieving file {}: {}", item_id, e), )); } }; - + let original_path = file.storage_path().to_string(); debug!("Original file path: {}", original_path); - + // Create the trash item debug!("Creating TrashedItem object for the file"); let trashed_item = TrashedItem::new( @@ -198,50 +211,62 @@ impl TrashUseCase for TrashService { original_path, self.retention_days, ); - debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id()); - + debug!( + "TrashedItem created successfully: {} -> {}", + file.name(), + trashed_item.id() + ); + // First add to trash index to register the item info!("Adding file {} to trash index", item_id); match self.trash_repository.add_to_trash(&trashed_item).await { Ok(_) => { debug!("File added to trash index successfully"); - }, + } Err(e) => { error!("Error adding file to trash index: {}", e); - return Err(DomainError::internal_error("TrashRepository", format!("Failed to add file to trash: {}", e))); + return Err(DomainError::internal_error( + "TrashRepository", + format!("Failed to add file to trash: {}", e), + )); } }; - + // Then physically move the file to trash info!("Physically moving file to trash: {}", item_id); match self.file_write_port.move_to_trash(item_id).await { Ok(_) => { debug!("File physically moved to trash successfully: {}", item_id); - }, + } Err(e) => { error!("Error physically moving file to trash: {} - {}", item_id, e); return Err(DomainError::new( ErrorKind::InternalError, "File", - format!("Error moving file {} to trash: {}", item_id, e) + format!("Error moving file {} to trash: {}", item_id, e), )); } } - + info!("File completely moved to trash: {}", item_id); Ok(()) - }, + } "folder" => { // Get the folder to verify it exists and capture its data - let folder = self.folder_storage_port.get_folder(item_id).await - .map_err(|e| DomainError::new( - ErrorKind::NotFound, - "Folder", - format!("Error retrieving folder {}: {}", item_id, e) - ))?; - + let folder = self + .folder_storage_port + .get_folder(item_id) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::NotFound, + "Folder", + format!("Error retrieving folder {}: {}", item_id, e), + ) + })?; + let original_path = folder.storage_path().to_string(); - + // Create the trash item let trashed_item = TrashedItem::new( item_uuid, @@ -251,66 +276,89 @@ impl TrashUseCase for TrashService { original_path, self.retention_days, ); - + // First add to trash index to register the item debug!("Adding folder {} to trash repository", item_id); match self.trash_repository.add_to_trash(&trashed_item).await { Ok(_) => debug!("Successfully added folder to trash repository"), Err(e) => { error!("Failed to add folder to trash repository: {}", e); - return Err(DomainError::internal_error("TrashRepository", format!("Failed to add folder to trash: {}", e))); + return Err(DomainError::internal_error( + "TrashRepository", + format!("Failed to add folder to trash: {}", e), + )); } }; - + // Then physically move the folder to trash - self.folder_storage_port.move_to_trash(item_id).await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "Folder", - format!("Error moving folder {} to trash: {}", item_id, e) - ))?; - + self.folder_storage_port + .move_to_trash(item_id) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Folder", + format!("Error moving folder {} to trash: {}", item_id, e), + ) + })?; + debug!("Folder moved to trash: {}", item_id); Ok(()) - }, - _ => Err(DomainError::validation_error(format!("Invalid item type: {}", item_type))), + } + _ => Err(DomainError::validation_error(format!( + "Invalid item type: {}", + item_type + ))), } } #[instrument(skip(self))] async fn restore_item(&self, trash_id: &str, user_id: &str) -> Result<()> { info!("Restoring item {} for user {}", trash_id, user_id); - + let trash_uuid = match Uuid::parse_str(trash_id) { Ok(id) => { info!("Trash UUID parsed successfully: {}", id); id - }, + } Err(e) => { error!("Invalid trash ID format: {} - {}", trash_id, e); - return Err(DomainError::validation_error(format!("Invalid trash ID: {}", e))); + return Err(DomainError::validation_error(format!( + "Invalid trash ID: {}", + e + ))); } }; - + let user_uuid = match Uuid::parse_str(user_id) { Ok(id) => { info!("User UUID parsed successfully: {}", id); id - }, + } Err(e) => { error!("Invalid user ID format: {} - {}", user_id, e); - return Err(DomainError::validation_error(format!("Invalid user ID: {}", e))); + return Err(DomainError::validation_error(format!( + "Invalid user ID: {}", + e + ))); } }; - + // Get the trash item info!("Retrieving trash item from repository: ID={}", trash_id); - let item_result = self.trash_repository.get_trash_item(&trash_uuid, &user_uuid).await; + let item_result = self + .trash_repository + .get_trash_item(&trash_uuid, &user_uuid) + .await; match item_result { Ok(Some(item)) => { - info!("Found item in trash: ID={}, Type={:?}, OriginalID={}", - trash_id, item.item_type(), item.original_id()); + info!( + "Found item in trash: ID={}, Type={:?}, OriginalID={}", + trash_id, + item.item_type(), + item.original_id() + ); // Restore based on type match item.item_type() { @@ -318,16 +366,26 @@ impl TrashUseCase for TrashService { // Restore the file to its original location let file_id = item.original_id().to_string(); let original_path = item.original_path().to_string(); - - info!("Restoring file from trash: ID={}, OriginalPath={}", file_id, original_path); - match self.file_write_port.restore_from_trash(&file_id, &original_path).await { + + info!( + "Restoring file from trash: ID={}, OriginalPath={}", + file_id, original_path + ); + match self + .file_write_port + .restore_from_trash(&file_id, &original_path) + .await + { Ok(_) => { info!("Successfully restored file from trash: {}", file_id); - }, + } Err(e) => { // Check if the error is because the file is not found if format!("{}", e).contains("not found") { - info!("File not found in trash, may already have been restored: {}", file_id); + info!( + "File not found in trash, may already have been restored: {}", + file_id + ); // We continue so we can clean up the trash entry } else { // Return error for other kinds of errors @@ -335,68 +393,103 @@ impl TrashUseCase for TrashService { return Err(DomainError::new( ErrorKind::InternalError, "File", - format!("Error restoring file {} from trash: {}", file_id, e) + format!( + "Error restoring file {} from trash: {}", + file_id, e + ), )); } } } - }, + } TrashedItemType::Folder => { // Restore the folder to its original location let folder_id = item.original_id().to_string(); let original_path = item.original_path().to_string(); - - info!("Restoring folder from trash: ID={}, OriginalPath={}", folder_id, original_path); - match self.folder_storage_port.restore_from_trash(&folder_id, &original_path).await { + + info!( + "Restoring folder from trash: ID={}, OriginalPath={}", + folder_id, original_path + ); + match self + .folder_storage_port + .restore_from_trash(&folder_id, &original_path) + .await + { Ok(_) => { info!("Successfully restored folder from trash: {}", folder_id); - }, + } Err(e) => { // Check if the error is because the folder is not found if format!("{}", e).contains("not found") { - info!("Folder not found in trash, may already have been restored: {}", folder_id); + info!( + "Folder not found in trash, may already have been restored: {}", + folder_id + ); // We continue so we can clean up the trash entry } else { // Return error for other kinds of errors - error!("Error restoring folder from trash: {} - {}", folder_id, e); + error!( + "Error restoring folder from trash: {} - {}", + folder_id, e + ); return Err(DomainError::new( ErrorKind::InternalError, "Folder", - format!("Error restoring folder {} from trash: {}", folder_id, e) + format!( + "Error restoring folder {} from trash: {}", + folder_id, e + ), )); } } } } } - + // Always remove the item from the trash index to maintain consistency - info!("Removing item from trash index after restoration: {}", trash_id); - match self.trash_repository.restore_from_trash(&trash_uuid, &user_uuid).await { + info!( + "Removing item from trash index after restoration: {}", + trash_id + ); + match self + .trash_repository + .restore_from_trash(&trash_uuid, &user_uuid) + .await + { Ok(_) => { info!("Successfully removed entry from trash index: {}", trash_id); - }, + } Err(e) => { - error!("Error removing entry from trash index: {} - {}", trash_id, e); + error!( + "Error removing entry from trash index: {} - {}", + trash_id, e + ); return Err(DomainError::new( ErrorKind::InternalError, "Trash", - format!("Error removing trash entry after restoration: {}", e) + format!("Error removing trash entry after restoration: {}", e), )); } } - + info!("Item successfully restored from trash: {}", trash_id); Ok(()) - }, + } Ok(None) => { // If the item isn't found in trash, we can just return success - info!("Item not found in trash index, considering as already restored: {}", trash_id); + info!( + "Item not found in trash index, considering as already restored: {}", + trash_id + ); Ok(()) - }, + } Err(e) => { // Something went wrong with the repository - error!("Error retrieving item from trash repository: {} - {}", trash_id, e); + error!( + "Error retrieving item from trash repository: {} - {}", + trash_id, e + ); Err(e) } } @@ -404,121 +497,169 @@ impl TrashUseCase for TrashService { #[instrument(skip(self))] async fn delete_permanently(&self, trash_id: &str, user_id: &str) -> Result<()> { - info!("Permanently deleting item {} for user {}", trash_id, user_id); - + info!( + "Permanently deleting item {} for user {}", + trash_id, user_id + ); + let trash_uuid = match Uuid::parse_str(trash_id) { Ok(id) => { info!("Trash UUID parsed successfully: {}", id); id - }, + } Err(e) => { error!("Invalid trash ID format: {} - {}", trash_id, e); - return Err(DomainError::validation_error(format!("Invalid trash ID: {}", e))); + return Err(DomainError::validation_error(format!( + "Invalid trash ID: {}", + e + ))); } }; - + let user_uuid = match Uuid::parse_str(user_id) { Ok(id) => { info!("User UUID parsed successfully: {}", id); id - }, + } Err(e) => { error!("Invalid user ID format: {} - {}", user_id, e); - return Err(DomainError::validation_error(format!("Invalid user ID: {}", e))); + return Err(DomainError::validation_error(format!( + "Invalid user ID: {}", + e + ))); } }; - + // Get the trash item info!("Retrieving trash item from repository: ID={}", trash_id); - let item_result = self.trash_repository.get_trash_item(&trash_uuid, &user_uuid).await; - + let item_result = self + .trash_repository + .get_trash_item(&trash_uuid, &user_uuid) + .await; + match item_result { Ok(Some(item)) => { - info!("Found item in trash: ID={}, Type={:?}, OriginalID={}", - trash_id, item.item_type(), item.original_id()); - + info!( + "Found item in trash: ID={}, Type={:?}, OriginalID={}", + trash_id, + item.item_type(), + item.original_id() + ); + // Permanently delete based on type match item.item_type() { TrashedItemType::File => { // Permanently delete the file let file_id = item.original_id().to_string(); - + info!("Permanently deleting file: {}", file_id); match self.file_write_port.delete_file_permanently(&file_id).await { Ok(_) => { info!("Successfully deleted file permanently: {}", file_id); - }, + } Err(e) => { // Check if the file is not found - in that case, we can continue // because we still want to remove the item from the trash index if format!("{}", e).contains("not found") { - info!("File not found, may already have been deleted: {}", file_id); + info!( + "File not found, may already have been deleted: {}", + file_id + ); } else { // Return error for other types of errors error!("Error permanently deleting file: {} - {}", file_id, e); return Err(DomainError::new( ErrorKind::InternalError, "File", - format!("Error deleting file {} permanently: {}", file_id, e) + format!( + "Error deleting file {} permanently: {}", + file_id, e + ), )); } } } - }, + } TrashedItemType::Folder => { // Permanently delete the folder let folder_id = item.original_id().to_string(); - + info!("Permanently deleting folder: {}", folder_id); - match self.folder_storage_port.delete_folder_permanently(&folder_id).await { + match self + .folder_storage_port + .delete_folder_permanently(&folder_id) + .await + { Ok(_) => { info!("Successfully deleted folder permanently: {}", folder_id); - }, + } Err(e) => { // Check if the folder is not found - in that case, we can continue if format!("{}", e).contains("not found") { - info!("Folder not found, may already have been deleted: {}", folder_id); + info!( + "Folder not found, may already have been deleted: {}", + folder_id + ); } else { // Return error for other types of errors - error!("Error permanently deleting folder: {} - {}", folder_id, e); + error!( + "Error permanently deleting folder: {} - {}", + folder_id, e + ); return Err(DomainError::new( ErrorKind::InternalError, "Folder", - format!("Error deleting folder {} permanently: {}", folder_id, e) + format!( + "Error deleting folder {} permanently: {}", + folder_id, e + ), )); } } } } } - + // Always remove the item from trash index to maintain consistency info!("Removing entry from trash index: {}", trash_id); - match self.trash_repository.delete_permanently(&trash_uuid, &user_uuid).await { + match self + .trash_repository + .delete_permanently(&trash_uuid, &user_uuid) + .await + { Ok(_) => { info!("Successfully removed entry from trash index: {}", trash_id); - }, + } Err(e) => { - error!("Error removing entry from trash index: {} - {}", trash_id, e); + error!( + "Error removing entry from trash index: {} - {}", + trash_id, e + ); return Err(DomainError::new( ErrorKind::InternalError, "Trash", - format!("Error removing trash entry: {}", e) + format!("Error removing trash entry: {}", e), )); } }; - + info!("Item permanently deleted from trash: {}", trash_id); Ok(()) - }, + } Ok(None) => { // If the item isn't found in trash, we can just return success - info!("Item not found in trash, considering as already deleted: {}", trash_id); + info!( + "Item not found in trash, considering as already deleted: {}", + trash_id + ); Ok(()) - }, + } Err(e) => { // Something went wrong with the repository - error!("Error retrieving item from trash repository: {} - {}", trash_id, e); + error!( + "Error retrieving item from trash repository: {} - {}", + trash_id, e + ); Err(e) } } @@ -527,13 +668,13 @@ impl TrashUseCase for TrashService { #[instrument(skip(self))] async fn empty_trash(&self, user_id: &str) -> Result<()> { info!("Emptying trash for user {}", user_id); - + let user_uuid = Uuid::parse_str(user_id) .map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?; - + // Get all items in the user's trash let items = self.trash_repository.get_trash_items(&user_uuid).await?; - + // Permanently delete each item for item in items { match item.item_type() { @@ -543,21 +684,25 @@ impl TrashUseCase for TrashService { if let Err(e) = self.file_write_port.delete_file_permanently(&file_id).await { error!("Error permanently deleting file {}: {}", file_id, e); } - }, + } TrashedItemType::Folder => { // Permanently delete the folder let folder_id = item.original_id().to_string(); - if let Err(e) = self.folder_storage_port.delete_folder_permanently(&folder_id).await { + if let Err(e) = self + .folder_storage_port + .delete_folder_permanently(&folder_id) + .await + { error!("Error permanently deleting folder {}: {}", folder_id, e); } } } } - + // Clear all trash records for this user self.trash_repository.clear_trash(&user_uuid).await?; - + info!("Trash completely emptied for user {}", user_id); Ok(()) } -} \ 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 e006e256..3ce14c8a 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -1,21 +1,21 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use std::path::PathBuf; -use chrono::Utc; use async_trait::async_trait; -use uuid::Uuid; use bytes::Bytes; +use chrono::Utc; use futures::Stream; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; -use crate::common::errors::{Result, DomainError}; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::services::trash_service::TrashService; +use crate::common::errors::{DomainError, Result}; use crate::domain::entities::file::File; use crate::domain::entities::folder::Folder; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; +use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::services::path_service::StoragePath; -use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; -use crate::domain::repositories::folder_repository::FolderRepository; -use crate::application::services::trash_service::TrashService; // Mock repositories for testing struct MockTrashRepository { @@ -40,7 +40,8 @@ impl TrashRepository for MockTrashRepository { async fn get_trash_items(&self, user_id: &Uuid) -> Result> { let items = self.trash_items.lock().unwrap(); - let user_items = items.values() + let user_items = items + .values() .filter(|item| item.user_id() == *user_id) .cloned() .collect(); @@ -49,7 +50,8 @@ impl TrashRepository for MockTrashRepository { async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { let items = self.trash_items.lock().unwrap(); - let item = items.get(id) + let item = items + .get(id) .filter(|item| item.user_id() == *user_id) .cloned(); Ok(item) @@ -58,18 +60,20 @@ impl TrashRepository for MockTrashRepository { async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> { let mut items = self.trash_items.lock().unwrap(); if let Some(item) = items.get(id) - && item.user_id() == *user_id { - items.remove(id); - } + && item.user_id() == *user_id + { + items.remove(id); + } Ok(()) } async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()> { let mut items = self.trash_items.lock().unwrap(); if let Some(item) = items.get(id) - && item.user_id() == *user_id { - items.remove(id); - } + && item.user_id() == *user_id + { + items.remove(id); + } Ok(()) } @@ -82,7 +86,8 @@ impl TrashRepository for MockTrashRepository { async fn get_expired_items(&self) -> Result> { let items = self.trash_items.lock().unwrap(); let now = Utc::now(); - let expired = items.values() + let expired = items + .values() .filter(|item| item.deletion_date() <= now) .cloned() .collect(); @@ -111,8 +116,9 @@ impl MockFileRepository { 100, "text/plain".to_string(), None, - ).unwrap(); - + ) + .unwrap(); + let mut files = self.files.lock().unwrap(); files.insert(id.to_string(), file); } @@ -129,7 +135,10 @@ impl FileReadPort for MockFileRepository { } } - async fn list_files(&self, _folder_id: Option<&str>) -> std::result::Result, DomainError> { + async fn list_files( + &self, + _folder_id: Option<&str>, + ) -> std::result::Result, DomainError> { Ok(vec![]) } @@ -140,7 +149,10 @@ impl FileReadPort for MockFileRepository { async fn get_file_stream( &self, _id: &str, - ) -> std::result::Result> + Send>, DomainError> { + ) -> std::result::Result< + Box> + Send>, + DomainError, + > { unimplemented!() } @@ -149,7 +161,10 @@ impl FileReadPort for MockFileRepository { _id: &str, _start: u64, _end: Option, - ) -> std::result::Result> + Send>, DomainError> { + ) -> std::result::Result< + Box> + Send>, + DomainError, + > { unimplemented!() } @@ -183,7 +198,9 @@ impl FileWritePort for MockFileRepository { _name: String, _folder_id: Option, _content_type: String, - _stream: std::pin::Pin> + Send>>, + _stream: std::pin::Pin< + Box> + Send>, + >, ) -> std::result::Result { unimplemented!() } @@ -208,7 +225,11 @@ impl FileWritePort for MockFileRepository { Ok(()) } - async fn update_file_content(&self, _file_id: &str, _content: Vec) -> std::result::Result<(), DomainError> { + async fn update_file_content( + &self, + _file_id: &str, + _content: Vec, + ) -> std::result::Result<(), DomainError> { Ok(()) } @@ -225,7 +246,7 @@ impl FileWritePort for MockFileRepository { async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); - + if let Some(file) = files.remove(id) { trashed.insert(id.to_string(), file); Ok(()) @@ -234,15 +255,22 @@ impl FileWritePort for MockFileRepository { } } - async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> { + async fn restore_from_trash( + &self, + id: &str, + _original_path: &str, + ) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); - + if let Some(file) = trashed.remove(id) { files.insert(id.to_string(), file); Ok(()) } else { - Err(DomainError::not_found("File", format!("File {} not found in trash", id))) + Err(DomainError::not_found( + "File", + format!("File {} not found in trash", id), + )) } } @@ -251,7 +279,10 @@ impl FileWritePort for MockFileRepository { if trashed.remove(id).is_some() { Ok(()) } else { - Err(DomainError::not_found("File", format!("File {} not found in trash", id))) + Err(DomainError::not_found( + "File", + format!("File {} not found in trash", id), + )) } } } @@ -275,8 +306,9 @@ impl MockFolderRepository { name.to_string(), StoragePath::from_string(path), None, - ).unwrap(); - + ) + .unwrap(); + let mut folders = self.folders.lock().unwrap(); folders.insert(id.to_string(), folder); } @@ -284,7 +316,11 @@ impl MockFolderRepository { #[async_trait] impl FolderRepository for MockFolderRepository { - async fn create_folder(&self, _name: String, _parent_id: Option) -> std::result::Result { + async fn create_folder( + &self, + _name: String, + _parent_id: Option, + ) -> std::result::Result { unimplemented!() } @@ -297,11 +333,17 @@ impl FolderRepository for MockFolderRepository { } } - async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> std::result::Result { + async fn get_folder_by_path( + &self, + _storage_path: &StoragePath, + ) -> std::result::Result { unimplemented!() } - async fn list_folders(&self, _parent_id: Option<&str>) -> std::result::Result, DomainError> { + async fn list_folders( + &self, + _parent_id: Option<&str>, + ) -> std::result::Result, DomainError> { Ok(vec![]) } @@ -315,11 +357,19 @@ impl FolderRepository for MockFolderRepository { Ok((vec![], Some(0))) } - async fn rename_folder(&self, _id: &str, _new_name: String) -> std::result::Result { + async fn rename_folder( + &self, + _id: &str, + _new_name: String, + ) -> std::result::Result { unimplemented!() } - async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> std::result::Result { + async fn move_folder( + &self, + _id: &str, + _new_parent_id: Option<&str>, + ) -> std::result::Result { unimplemented!() } @@ -327,7 +377,10 @@ impl FolderRepository for MockFolderRepository { Ok(()) } - async fn folder_exists(&self, _storage_path: &StoragePath) -> std::result::Result { + async fn folder_exists( + &self, + _storage_path: &StoragePath, + ) -> std::result::Result { Ok(false) } @@ -338,7 +391,7 @@ impl FolderRepository for MockFolderRepository { async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); - + if let Some(folder) = folders.remove(id) { trashed.insert(id.to_string(), folder); Ok(()) @@ -347,15 +400,22 @@ impl FolderRepository for MockFolderRepository { } } - async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> { + async fn restore_from_trash( + &self, + id: &str, + _original_path: &str, + ) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); - + if let Some(folder) = trashed.remove(id) { folders.insert(id.to_string(), folder); Ok(()) } else { - Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id))) + Err(DomainError::not_found( + "Folder", + format!("Folder {} not found in trash", id), + )) } } @@ -364,7 +424,10 @@ impl FolderRepository for MockFolderRepository { if trashed.remove(id).is_some() { Ok(()) } else { - Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id))) + Err(DomainError::not_found( + "Folder", + format!("Folder {} not found in trash", id), + )) } } } @@ -380,7 +443,7 @@ mod tests { let trash_repo = Arc::new(MockTrashRepository::new()); let file_repo = Arc::new(MockFileRepository::new()); let folder_repo = Arc::new(MockFolderRepository::new()); - + let service = TrashService::new( trash_repo.clone(), file_repo.clone() as Arc, @@ -388,37 +451,59 @@ mod tests { folder_repo.clone(), 30, // 30 days retention ); - + let file_id = "550e8400-e29b-41d4-a716-446655440000"; let user_id = "550e8400-e29b-41d4-a716-446655440001"; - + // Add a test file to the repository file_repo.add_test_file(file_id, "test.txt", "/test/path/test.txt"); - + // Act let result = service.move_to_trash(file_id, "file", user_id).await; - + // Assert assert!(result.is_ok(), "Moving file to trash failed: {:?}", result); - + // Verify the file is in trash let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); - - assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash"); + + assert_eq!( + trash_items.len(), + 1, + "Should have exactly one item in trash" + ); let trash_item = &trash_items[0]; - - assert_eq!(trash_item.original_id().to_string(), file_id, "Original ID should match file ID"); - assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match"); - assert_eq!(*trash_item.item_type(), TrashedItemType::File, "Item type should be File"); + + assert_eq!( + trash_item.original_id().to_string(), + file_id, + "Original ID should match file ID" + ); + assert_eq!( + trash_item.user_id().to_string(), + user_id, + "User ID should match" + ); + assert_eq!( + *trash_item.item_type(), + TrashedItemType::File, + "Item type should be File" + ); assert_eq!(trash_item.name(), "test.txt", "File name should match"); - + // Verify file is moved in file repository let files = file_repo.files.lock().unwrap(); let trashed_files = file_repo.trashed_files.lock().unwrap(); - - assert!(files.get(file_id).is_none(), "File should no longer be in main storage"); - assert!(trashed_files.get(file_id).is_some(), "File should be in trash storage"); + + assert!( + files.get(file_id).is_none(), + "File should no longer be in main storage" + ); + assert!( + trashed_files.get(file_id).is_some(), + "File should be in trash storage" + ); } #[tokio::test] @@ -427,7 +512,7 @@ mod tests { let trash_repo = Arc::new(MockTrashRepository::new()); let file_repo = Arc::new(MockFileRepository::new()); let folder_repo = Arc::new(MockFolderRepository::new()); - + let service = TrashService::new( trash_repo.clone(), file_repo.clone() as Arc, @@ -435,29 +520,49 @@ mod tests { folder_repo.clone(), 30, // 30 days retention ); - + let folder_id = "550e8400-e29b-41d4-a716-446655440002"; let user_id = "550e8400-e29b-41d4-a716-446655440001"; - + // Add a test folder to the repository folder_repo.add_test_folder(folder_id, "test_folder", "/test/path/test_folder"); - + // Act let result = service.move_to_trash(folder_id, "folder", user_id).await; - + // Assert - assert!(result.is_ok(), "Moving folder to trash failed: {:?}", result); - + assert!( + result.is_ok(), + "Moving folder to trash failed: {:?}", + result + ); + // Verify the folder is in trash let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); - - assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash"); + + assert_eq!( + trash_items.len(), + 1, + "Should have exactly one item in trash" + ); let trash_item = &trash_items[0]; - - assert_eq!(trash_item.original_id().to_string(), folder_id, "Original ID should match folder ID"); - assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match"); - assert_eq!(*trash_item.item_type(), TrashedItemType::Folder, "Item type should be Folder"); + + assert_eq!( + trash_item.original_id().to_string(), + folder_id, + "Original ID should match folder ID" + ); + assert_eq!( + trash_item.user_id().to_string(), + user_id, + "User ID should match" + ); + assert_eq!( + *trash_item.item_type(), + TrashedItemType::Folder, + "Item type should be Folder" + ); assert_eq!(trash_item.name(), "test_folder", "Folder name should match"); } @@ -467,7 +572,7 @@ mod tests { let trash_repo = Arc::new(MockTrashRepository::new()); let file_repo = Arc::new(MockFileRepository::new()); let folder_repo = Arc::new(MockFolderRepository::new()); - + let service = TrashService::new( trash_repo.clone(), file_repo.clone() as Arc, @@ -475,36 +580,53 @@ mod tests { folder_repo.clone(), 30, // 30 days retention ); - + let file_id = "550e8400-e29b-41d4-a716-446655440000"; let user_id = "550e8400-e29b-41d4-a716-446655440001"; let file_path = "/test/path/test.txt"; - + // Add a test file and move it to trash file_repo.add_test_file(file_id, "test.txt", file_path); - service.move_to_trash(file_id, "file", user_id).await.unwrap(); - + service + .move_to_trash(file_id, "file", user_id) + .await + .unwrap(); + // Get the trash item ID let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); let trash_id = trash_items[0].id().to_string(); - + // Act let result = service.restore_item(&trash_id, user_id).await; - + // Assert - assert!(result.is_ok(), "Restoring file from trash failed: {:?}", result); - + assert!( + result.is_ok(), + "Restoring file from trash failed: {:?}", + result + ); + // Verify the file is restored in file repository let files = file_repo.files.lock().unwrap(); let trashed_files = file_repo.trashed_files.lock().unwrap(); - - assert!(files.get(file_id).is_some(), "File should be back in main storage"); - assert!(trashed_files.get(file_id).is_none(), "File should no longer be in trash storage"); - + + assert!( + files.get(file_id).is_some(), + "File should be back in main storage" + ); + assert!( + trashed_files.get(file_id).is_none(), + "File should no longer be in trash storage" + ); + // Verify the trash item is removed let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); - assert_eq!(trash_items.len(), 0, "Trash should be empty after restoration"); + assert_eq!( + trash_items.len(), + 0, + "Trash should be empty after restoration" + ); } #[tokio::test] @@ -513,7 +635,7 @@ mod tests { let trash_repo = Arc::new(MockTrashRepository::new()); let file_repo = Arc::new(MockFileRepository::new()); let folder_repo = Arc::new(MockFolderRepository::new()); - + let service = TrashService::new( trash_repo.clone(), file_repo.clone() as Arc, @@ -521,35 +643,52 @@ mod tests { folder_repo.clone(), 30, // 30 days retention ); - + let file_id = "550e8400-e29b-41d4-a716-446655440000"; let user_id = "550e8400-e29b-41d4-a716-446655440001"; - + // Add a test file and move it to trash file_repo.add_test_file(file_id, "test.txt", "/test/path/test.txt"); - service.move_to_trash(file_id, "file", user_id).await.unwrap(); - + service + .move_to_trash(file_id, "file", user_id) + .await + .unwrap(); + // Get the trash item ID let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); let trash_id = trash_items[0].id().to_string(); - + // Act let result = service.delete_permanently(&trash_id, user_id).await; - + // Assert - assert!(result.is_ok(), "Deleting file permanently failed: {:?}", result); - + assert!( + result.is_ok(), + "Deleting file permanently failed: {:?}", + result + ); + // Verify the file is permanently deleted let files = file_repo.files.lock().unwrap(); let trashed_files = file_repo.trashed_files.lock().unwrap(); - - assert!(files.get(file_id).is_none(), "File should not be in main storage"); - assert!(trashed_files.get(file_id).is_none(), "File should not be in trash storage"); - + + assert!( + files.get(file_id).is_none(), + "File should not be in main storage" + ); + assert!( + trashed_files.get(file_id).is_none(), + "File should not be in trash storage" + ); + // Verify the trash item is removed let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); - assert_eq!(trash_items.len(), 0, "Trash should be empty after permanent deletion"); + assert_eq!( + trash_items.len(), + 0, + "Trash should be empty after permanent deletion" + ); } #[tokio::test] @@ -558,7 +697,7 @@ mod tests { let trash_repo = Arc::new(MockTrashRepository::new()); let file_repo = Arc::new(MockFileRepository::new()); let folder_repo = Arc::new(MockFolderRepository::new()); - + let service = TrashService::new( trash_repo.clone(), file_repo.clone() as Arc, @@ -566,59 +705,85 @@ mod tests { folder_repo.clone(), 30, // 30 days retention ); - + let user_id = "550e8400-e29b-41d4-a716-446655440001"; - + // Add multiple files and folders to trash let file_ids = [ "550e8400-e29b-41d4-a716-446655440010", "550e8400-e29b-41d4-a716-446655440011", ]; - + let folder_ids = [ "550e8400-e29b-41d4-a716-446655440020", "550e8400-e29b-41d4-a716-446655440021", ]; - + // Add test files and folders for (i, file_id) in file_ids.iter().enumerate() { - file_repo.add_test_file(file_id, &format!("test{}.txt", i), &format!("/test/path/test{}.txt", i)); - service.move_to_trash(file_id, "file", user_id).await.unwrap(); + file_repo.add_test_file( + file_id, + &format!("test{}.txt", i), + &format!("/test/path/test{}.txt", i), + ); + service + .move_to_trash(file_id, "file", user_id) + .await + .unwrap(); } - + for (i, folder_id) in folder_ids.iter().enumerate() { - folder_repo.add_test_folder(folder_id, &format!("folder{}", i), &format!("/test/path/folder{}", i)); - service.move_to_trash(folder_id, "folder", user_id).await.unwrap(); + folder_repo.add_test_folder( + folder_id, + &format!("folder{}", i), + &format!("/test/path/folder{}", i), + ); + service + .move_to_trash(folder_id, "folder", user_id) + .await + .unwrap(); } - + // Verify items are in trash let user_uuid = Uuid::parse_str(user_id).unwrap(); let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); assert_eq!(trash_items.len(), 4, "Should have 4 items in trash"); - + // Act let result = service.empty_trash(user_id).await; - + // Assert assert!(result.is_ok(), "Emptying trash failed: {:?}", result); - + // Verify all items are permanently deleted for file_id in &file_ids { let files = file_repo.files.lock().unwrap(); let trashed_files = file_repo.trashed_files.lock().unwrap(); - assert!(files.get(*file_id).is_none(), "File should not be in main storage"); - assert!(trashed_files.get(*file_id).is_none(), "File should not be in trash storage"); + assert!( + files.get(*file_id).is_none(), + "File should not be in main storage" + ); + assert!( + trashed_files.get(*file_id).is_none(), + "File should not be in trash storage" + ); } - + for folder_id in &folder_ids { let folders = folder_repo.folders.lock().unwrap(); let trashed_folders = folder_repo.trashed_folders.lock().unwrap(); - assert!(folders.get(*folder_id).is_none(), "Folder should not be in main storage"); - assert!(trashed_folders.get(*folder_id).is_none(), "Folder should not be in trash storage"); + assert!( + folders.get(*folder_id).is_none(), + "Folder should not be in main storage" + ); + assert!( + trashed_folders.get(*folder_id).is_none(), + "Folder should not be in trash storage" + ); } - + // Verify the trash is empty let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap(); assert_eq!(trash_items.len(), 0, "Trash should be empty after emptying"); } -} \ No newline at end of file +} diff --git a/src/application/transactions/mod.rs b/src/application/transactions/mod.rs index 7aba9634..f9f4dff9 100644 --- a/src/application/transactions/mod.rs +++ b/src/application/transactions/mod.rs @@ -1 +1 @@ -pub mod storage_transaction; \ No newline at end of file +pub mod storage_transaction; diff --git a/src/application/transactions/storage_transaction.rs b/src/application/transactions/storage_transaction.rs index afd32b41..d9aef65b 100644 --- a/src/application/transactions/storage_transaction.rs +++ b/src/application/transactions/storage_transaction.rs @@ -1,6 +1,6 @@ +use crate::common::errors::{DomainError, ErrorKind}; use std::future::Future; use std::pin::Pin; -use crate::common::errors::{DomainError, ErrorKind}; /// Type for async operations and rollbacks type TransactionOp = Pin> + Send>>; @@ -25,7 +25,7 @@ impl StorageTransaction { name: name.to_string(), } } - + /// Adds an operation to the transaction with its corresponding rollback pub fn add_operation(&mut self, operation: F, rollback: R) where @@ -35,7 +35,7 @@ impl StorageTransaction { self.operations.push(Box::new(move || Box::pin(operation))); self.rollbacks.push(Box::new(move || Box::pin(rollback))); } - + /// Adds an operation without rollback (for cleanup or logging) pub fn add_finalizer(&mut self, finalizer: F) where @@ -43,58 +43,68 @@ impl StorageTransaction { { // The rollback is a no-op let noop = async { Ok(()) }; - + self.operations.push(Box::new(move || Box::pin(finalizer))); self.rollbacks.push(Box::new(move || Box::pin(noop))); } - + /// Executes the transaction by applying all operations in order /// If any fails, executes rollbacks in reverse order pub async fn commit(mut self) -> Result<(), DomainError> { tracing::debug!("Starting transaction: {}", self.name); - + let mut completed_ops = Vec::new(); - + // Extract operations to avoid ownership issues let operations = std::mem::take(&mut self.operations); let transaction_name = self.name.clone(); - + // Execute operations for (i, op) in operations.into_iter().enumerate() { match op().await { Ok(()) => { completed_ops.push(i); - tracing::trace!("Operation {} completed in transaction: {}", i, transaction_name); + tracing::trace!( + "Operation {} completed in transaction: {}", + i, + transaction_name + ); } Err(e) => { - tracing::error!("Error in operation {} of transaction {}: {}", i, transaction_name, e); - + tracing::error!( + "Error in operation {} of transaction {}: {}", + i, + transaction_name, + e + ); + // Execute rollbacks for completed operations in reverse order self.rollback(completed_ops).await?; - + return Err(DomainError::new( ErrorKind::InternalError, "Transaction", - format!("Transaction '{}' failed: {}", transaction_name, e) - ).with_source(e)); + format!("Transaction '{}' failed: {}", transaction_name, e), + ) + .with_source(e)); } } } - + tracing::debug!("Transaction completed successfully: {}", transaction_name); Ok(()) } - + /// Executes rollbacks for completed operations async fn rollback(mut self, completed_ops: Vec) -> Result<(), DomainError> { tracing::warn!("Starting rollback for transaction: {}", self.name); - + let mut rollback_errors = Vec::new(); - + // Extract rollbacks to avoid ownership issues let mut rollbacks = Vec::new(); std::mem::swap(&mut rollbacks, &mut self.rollbacks); - + // Execute rollbacks in reverse order for i in completed_ops.into_iter().rev() { if i < rollbacks.len() { @@ -103,28 +113,38 @@ impl StorageTransaction { // Swap with an empty function let rollback = std::mem::replace(rb, Box::new(|| Box::pin(async { Ok(()) }))); if let Err(e) = rollback().await { - tracing::error!("Error in rollback of operation {} in transaction {}: {}", - i, self.name, e); + tracing::error!( + "Error in rollback of operation {} in transaction {}: {}", + i, + self.name, + e + ); rollback_errors.push(e); } } } } - + // If there were errors during rollback, report them if !rollback_errors.is_empty() { - tracing::error!("Errors during transaction rollback {}: {} errors", - self.name, rollback_errors.len()); - + tracing::error!( + "Errors during transaction rollback {}: {} errors", + self.name, + rollback_errors.len() + ); + return Err(DomainError::new( ErrorKind::InternalError, "Transaction", - format!("Errors during transaction '{}' rollback: {} errors", - self.name, rollback_errors.len()) + format!( + "Errors during transaction '{}' rollback: {} errors", + self.name, + rollback_errors.len() + ), )); } - + tracing::info!("Transaction rollback completed: {}", self.name); Ok(()) } -} \ No newline at end of file +} diff --git a/src/bin/migrate.rs b/src/bin/migrate.rs index c7b44f33..155356e6 100644 --- a/src/bin/migrate.rs +++ b/src/bin/migrate.rs @@ -7,7 +7,7 @@ use std::time::Duration; async fn main() -> Result<(), Box> { // Configure logging tracing_subscriber::fmt::init(); - + // Load environment variables (.env.local first, then .env) if let Ok(path) = env::var("DOTENV_PATH") { dotenv::from_path(Path::new(&path)).ok(); @@ -15,35 +15,35 @@ async fn main() -> Result<(), Box> { dotenv::from_filename(".env.local").ok(); dotenv::dotenv().ok(); } - + // Get DATABASE_URL from environment variables let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be configured"); - + println!("Connecting to the database..."); - + // Create connection pool let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(10)) .connect(&database_url) .await?; - + // Run migrations println!("Running migrations..."); - + // Get the directory from an environment variable or use a default value let migrations_dir = env::var("MIGRATIONS_DIR").unwrap_or_else(|_| "./migrations".to_string()); println!("Migrations directory: {}", migrations_dir); - + // Create a migrator let migrator = sqlx::migrate::Migrator::new(Path::new(&migrations_dir)) .await .expect("Could not create the migrator"); - + // Run all pending migrations migrator.run(&pool).await?; - + println!("Migrations applied successfully"); - + Ok(()) -} \ No newline at end of file +} diff --git a/src/common/config.rs b/src/common/config.rs index be4e0699..2017ec28 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1,6 +1,6 @@ -use std::time::Duration; -use std::path::PathBuf; use std::env; +use std::path::PathBuf; +use std::time::Duration; /// Cache configuration #[derive(Debug, Clone)] @@ -16,9 +16,9 @@ pub struct CacheConfig { impl Default for CacheConfig { fn default() -> Self { Self { - file_ttl_ms: 60_000, // 1 minute + file_ttl_ms: 60_000, // 1 minute directory_ttl_ms: 120_000, // 2 minutes - max_entries: 10_000, // 10,000 entries + max_entries: 10_000, // 10,000 entries } } } @@ -100,10 +100,10 @@ pub struct ResourceConfig { impl Default for ResourceConfig { fn default() -> Self { Self { - large_file_threshold_mb: 100, // 100 MB - large_dir_threshold_entries: 1000, // 1000 entries - chunk_size_bytes: 1024 * 1024, // 1 MB - max_in_memory_file_size_mb: 50, // 50 MB + large_file_threshold_mb: 100, // 100 MB + large_dir_threshold_entries: 1000, // 1000 entries + chunk_size_bytes: 1024 * 1024, // 1 MB + max_in_memory_file_size_mb: 50, // 50 MB } } } @@ -118,7 +118,7 @@ impl ResourceConfig { pub fn is_large_file(&self, size_bytes: u64) -> bool { self.bytes_to_mb(size_bytes) >= self.large_file_threshold_mb } - + /// Determines if a file is large enough for parallel processing pub fn needs_parallel_processing(&self, size_bytes: u64, config: &ConcurrencyConfig) -> bool { self.bytes_to_mb(size_bytes) >= config.min_size_for_parallel_chunks_mb @@ -133,27 +133,27 @@ impl ResourceConfig { pub fn is_large_directory(&self, entry_count: usize) -> bool { entry_count >= self.large_dir_threshold_entries } - + /// Calculates the number of chunks for parallel processing pub fn calculate_optimal_chunks(&self, size_bytes: u64, config: &ConcurrencyConfig) -> usize { // If the file is not large enough, return 1 if !self.needs_parallel_processing(size_bytes, config) { return 1; } - + // Calculate the number of chunks based on size let chunk_count = (size_bytes as usize).div_ceil(config.parallel_chunk_size_bytes); - + // Limit to the maximum number of parallel chunks chunk_count.min(config.max_parallel_chunks) } - + /// Calculates the optimal size of each chunk for parallel processing pub fn calculate_chunk_size(&self, file_size: u64, chunk_count: usize) -> usize { if chunk_count <= 1 { return file_size as usize; } - + // Distribute the size evenly among the chunks (file_size as usize).div_ceil(chunk_count) } @@ -183,7 +183,7 @@ impl Default for ConcurrencyConfig { max_concurrent_dirs: 5, max_concurrent_io: 20, max_parallel_chunks: 8, - min_size_for_parallel_chunks_mb: 200, // 200 MB + min_size_for_parallel_chunks_mb: 200, // 200 MB parallel_chunk_size_bytes: 8 * 1024 * 1024, // 8 MB } } @@ -206,9 +206,9 @@ impl Default for StorageConfig { fn default() -> Self { Self { root_dir: "storage".to_string(), - chunk_size: 1024 * 1024, // 1 MB + chunk_size: 1024 * 1024, // 1 MB parallel_threshold: 100 * 1024 * 1024, // 100 MB - trash_retention_days: 30, // 30 days + trash_retention_days: 30, // 30 days } } } @@ -255,9 +255,9 @@ impl Default for AuthConfig { // to set OXICLOUD_JWT_SECRET in production. The from_env() method // will validate this and warn/panic if not configured. jwt_secret: String::new(), - access_token_expiry_secs: 3600, // 1 hour + access_token_expiry_secs: 3600, // 1 hour refresh_token_expiry_secs: 2592000, // 30 days - hash_memory_cost: 65536, // 64MB + hash_memory_cost: 65536, // 64MB hash_time_cost: 3, } } @@ -316,20 +316,36 @@ impl OidcConfig { if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") { cfg.enabled = v.parse::().unwrap_or(false); } - if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { cfg.issuer_url = v; } - if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { cfg.client_id = v; } - if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { cfg.client_secret = v; } - if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { cfg.redirect_uri = v; } - if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { cfg.scopes = v; } - if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { cfg.frontend_url = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { + cfg.issuer_url = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { + cfg.client_id = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { + cfg.client_secret = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { + cfg.redirect_uri = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { + cfg.scopes = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { + cfg.frontend_url = v; + } if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") { cfg.auto_provision = v.parse::().unwrap_or(true); } - if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { cfg.admin_groups = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { + cfg.admin_groups = v; + } if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") { cfg.disable_password_login = v.parse::().unwrap_or(false); } - if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { cfg.provider_name = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { + cfg.provider_name = v; + } cfg } } @@ -347,11 +363,11 @@ pub struct FeaturesConfig { impl Default for FeaturesConfig { fn default() -> Self { Self { - enable_auth: true, // Enable authentication by default + enable_auth: true, // Enable authentication by default enable_user_storage_quotas: false, - enable_file_sharing: true, // Enable file sharing by default - enable_trash: true, // Enable trash feature - enable_search: true, // Enable search feature + enable_file_sharing: true, // Enable file sharing by default + enable_trash: true, // Enable trash feature + enable_search: true, // Enable search feature } } } @@ -410,47 +426,50 @@ impl Default for AppConfig { impl AppConfig { pub fn from_env() -> Self { let mut config = Self::default(); - + // Use environment variables to override default values if let Ok(storage_path) = env::var("OXICLOUD_STORAGE_PATH") { config.storage_path = PathBuf::from(storage_path); } - + if let Ok(static_path) = env::var("OXICLOUD_STATIC_PATH") { config.static_path = PathBuf::from(static_path); } - + if let Ok(server_port) = env::var("OXICLOUD_SERVER_PORT") - && let Ok(port) = server_port.parse::() { - config.server_port = port; - } - + && let Ok(port) = server_port.parse::() + { + config.server_port = port; + } + if let Ok(server_host) = env::var("OXICLOUD_SERVER_HOST") { config.server_host = server_host; } - + // Database configuration if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") { config.database.connection_string = connection_string; } - - if let Ok(max_connections) = env::var("OXICLOUD_DB_MAX_CONNECTIONS") - .map(|v| v.parse::()) - && let Ok(val) = max_connections { - config.database.max_connections = val; - } - - if let Ok(min_connections) = env::var("OXICLOUD_DB_MIN_CONNECTIONS") - .map(|v| v.parse::()) - && let Ok(val) = min_connections { - config.database.min_connections = val; - } - + + if let Ok(max_connections) = + env::var("OXICLOUD_DB_MAX_CONNECTIONS").map(|v| v.parse::()) + && let Ok(val) = max_connections + { + config.database.max_connections = val; + } + + if let Ok(min_connections) = + env::var("OXICLOUD_DB_MIN_CONNECTIONS").map(|v| v.parse::()) + && let Ok(val) = min_connections + { + config.database.min_connections = val; + } + // Auth configuration if let Ok(jwt_secret) = env::var("OXICLOUD_JWT_SECRET") { config.auth.jwt_secret = jwt_secret; } - + // SECURITY: Validate JWT secret when auth is enabled if config.features.enable_auth && config.auth.jwt_secret.is_empty() { // Generate a random secret for this session and warn loudly @@ -459,7 +478,7 @@ impl AppConfig { OsRng.fill_bytes(&mut key); let generated_secret: String = key.iter().map(|b| format!("{:02x}", b)).collect(); config.auth.jwt_secret = generated_secret; - + tracing::warn!("=========================================================="); tracing::warn!("OXICLOUD_JWT_SECRET is not set."); tracing::warn!("A random secret has been generated for this session."); @@ -467,50 +486,54 @@ impl AppConfig { tracing::warn!("Set OXICLOUD_JWT_SECRET env var for production use."); tracing::warn!("=========================================================="); } - - if let Ok(access_token_expiry) = env::var("OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS") - .map(|v| v.parse::()) - && let Ok(val) = access_token_expiry { - config.auth.access_token_expiry_secs = val; - } - - if let Ok(refresh_token_expiry) = env::var("OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS") - .map(|v| v.parse::()) - && let Ok(val) = refresh_token_expiry { - config.auth.refresh_token_expiry_secs = val; - } - + + if let Ok(access_token_expiry) = + env::var("OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS").map(|v| v.parse::()) + && let Ok(val) = access_token_expiry + { + config.auth.access_token_expiry_secs = val; + } + + if let Ok(refresh_token_expiry) = + env::var("OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS").map(|v| v.parse::()) + && let Ok(val) = refresh_token_expiry + { + config.auth.refresh_token_expiry_secs = val; + } + // Feature flags - if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH") - .map(|v| v.parse::()) - && let Ok(val) = enable_auth { - config.features.enable_auth = val; - } - - if let Ok(enable_user_storage_quotas) = env::var("OXICLOUD_ENABLE_USER_STORAGE_QUOTAS") - .map(|v| v.parse::()) - && let Ok(val) = enable_user_storage_quotas { - config.features.enable_user_storage_quotas = val; - } - - if let Ok(enable_file_sharing) = env::var("OXICLOUD_ENABLE_FILE_SHARING") - .map(|v| v.parse::()) - && let Ok(val) = enable_file_sharing { - config.features.enable_file_sharing = val; - } - - if let Ok(enable_trash) = env::var("OXICLOUD_ENABLE_TRASH") - .map(|v| v.parse::()) - && let Ok(val) = enable_trash { - config.features.enable_trash = val; - } - - if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH") - .map(|v| v.parse::()) - && let Ok(val) = enable_search { - config.features.enable_search = val; - } - + if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::()) + && let Ok(val) = enable_auth + { + config.features.enable_auth = val; + } + + if let Ok(enable_user_storage_quotas) = + env::var("OXICLOUD_ENABLE_USER_STORAGE_QUOTAS").map(|v| v.parse::()) + && let Ok(val) = enable_user_storage_quotas + { + config.features.enable_user_storage_quotas = val; + } + + if let Ok(enable_file_sharing) = + env::var("OXICLOUD_ENABLE_FILE_SHARING").map(|v| v.parse::()) + && let Ok(val) = enable_file_sharing + { + config.features.enable_file_sharing = val; + } + + if let Ok(enable_trash) = env::var("OXICLOUD_ENABLE_TRASH").map(|v| v.parse::()) + && let Ok(val) = enable_trash + { + config.features.enable_trash = val; + } + + if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::()) + && let Ok(val) = enable_search + { + config.features.enable_search = val; + } + // OIDC configuration if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") { config.oidc.enabled = v.parse::().unwrap_or(false); @@ -548,23 +571,28 @@ impl AppConfig { // Validate OIDC config when enabled if config.oidc.enabled - && (config.oidc.issuer_url.is_empty() || config.oidc.client_id.is_empty() || config.oidc.client_secret.is_empty()) { - tracing::error!("OIDC is enabled but OXICLOUD_OIDC_ISSUER_URL, OXICLOUD_OIDC_CLIENT_ID, or OXICLOUD_OIDC_CLIENT_SECRET are not set"); - config.oidc.enabled = false; - } + && (config.oidc.issuer_url.is_empty() + || config.oidc.client_id.is_empty() + || config.oidc.client_secret.is_empty()) + { + tracing::error!( + "OIDC is enabled but OXICLOUD_OIDC_ISSUER_URL, OXICLOUD_OIDC_CLIENT_ID, or OXICLOUD_OIDC_CLIENT_SECRET are not set" + ); + config.oidc.enabled = false; + } config } - + pub fn with_features(mut self, features: FeaturesConfig) -> Self { self.features = features; self } - + pub fn db_enabled(&self) -> bool { self.features.enable_auth } - + pub fn auth_enabled(&self) -> bool { self.features.enable_auth } @@ -595,4 +623,4 @@ impl AppConfig { /// Gets a default global configuration pub fn default_config() -> AppConfig { AppConfig::default() -} \ No newline at end of file +} diff --git a/src/common/di.rs b/src/common/di.rs index 07bca866..e6233c0f 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1,60 +1,65 @@ +use sqlx::PgPool; use std::path::PathBuf; use std::sync::Arc; -use sqlx::PgPool; -use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::admin_settings_service::AdminSettingsService; +use crate::application::services::auth_application_service::AuthApplicationService; -use crate::infrastructure::services::path_service::PathService; -use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository; -use crate::infrastructure::repositories::trash_fs_repository::TrashFsRepository; -use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository; -use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; -use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; -use crate::infrastructure::services::id_mapping_service::IdMappingService; -use crate::infrastructure::services::id_mapping_optimizer::IdMappingOptimizer; -use crate::infrastructure::services::file_metadata_cache::FileMetadataCache; -use crate::infrastructure::services::file_content_cache::{FileContentCache, FileContentCacheConfig}; -use crate::infrastructure::services::buffer_pool::BufferPool; -use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; -use crate::application::services::folder_service::FolderService; -use crate::application::services::i18n_application_service::I18nApplicationService; -use crate::application::services::trash_service::TrashService; -use crate::application::services::search_service::SearchService; -use crate::application::services::share_service::ShareService; -use crate::application::services::favorites_service::FavoritesService; -use crate::application::services::recent_service::RecentService; -use crate::application::ports::trash_ports::TrashUseCase; -use crate::application::services::storage_mediator::{StorageMediator, FileSystemStorageMediator}; -use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; -use crate::application::ports::outbound::FolderStoragePort; -use crate::application::ports::favorites_ports::FavoritesUseCase; -use crate::application::ports::recent_ports::RecentItemsUseCase; -use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory}; -use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; -use crate::infrastructure::repositories::{FileFsReadRepository, FileFsWriteRepository}; -use crate::application::services::{FileUploadService, FileRetrievalService, FileManagementService, AppFileUseCaseFactory}; -use crate::common::errors::DomainError; -use crate::domain::services::i18n_service::I18nService; -use crate::common::config::AppConfig; -use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort}; -use crate::application::ports::thumbnail_ports::ThumbnailPort; -use crate::application::ports::transcode_ports::ImageTranscodePort; -use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::cache_ports::{ContentCachePort, WriteBehindCachePort}; use crate::application::ports::chunked_upload_ports::ChunkedUploadPort; use crate::application::ports::compression_ports::CompressionPort; +use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::favorites_ports::FavoritesUseCase; +use crate::application::ports::file_ports::{ + FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, +}; +use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::outbound::FolderStoragePort; +use crate::application::ports::recent_ports::RecentItemsUseCase; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::ports::thumbnail_ports::ThumbnailPort; +use crate::application::ports::transcode_ports::ImageTranscodePort; +use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::zip_ports::ZipPort; +use crate::application::services::favorites_service::FavoritesService; +use crate::application::services::folder_service::FolderService; +use crate::application::services::i18n_application_service::I18nApplicationService; +use crate::application::services::recent_service::RecentService; +use crate::application::services::search_service::SearchService; +use crate::application::services::share_service::ShareService; +use crate::application::services::storage_mediator::{FileSystemStorageMediator, StorageMediator}; +use crate::application::services::trash_service::TrashService; +use crate::application::services::{ + AppFileUseCaseFactory, FileManagementService, FileRetrievalService, FileUploadService, +}; +use crate::common::config::AppConfig; +use crate::common::errors::DomainError; +use crate::domain::services::i18n_service::I18nService; +use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository; +use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; +use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository; +use crate::infrastructure::repositories::trash_fs_repository::TrashFsRepository; +use crate::infrastructure::repositories::{FileFsReadRepository, FileFsWriteRepository}; +use crate::infrastructure::services::buffer_pool::BufferPool; +use crate::infrastructure::services::file_content_cache::{ + FileContentCache, FileContentCacheConfig, +}; +use crate::infrastructure::services::file_metadata_cache::FileMetadataCache; +use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; +use crate::infrastructure::services::id_mapping_optimizer::IdMappingOptimizer; +use crate::infrastructure::services::id_mapping_service::IdMappingService; +use crate::infrastructure::services::path_service::PathService; +use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::common::stubs::{ - StubZipPort, StubCompressionPort, StubIdMappingService, StubStorageMediator, - StubFileReadPort, StubFileWritePort, StubFolderStoragePort, - StubI18nService, StubFolderUseCase, StubFileUploadUseCase, - StubFileRetrievalUseCase, StubFileManagementUseCase, StubFileUseCaseFactory, - StubSearchUseCase, + StubCompressionPort, StubFileManagementUseCase, StubFileReadPort, StubFileRetrievalUseCase, + StubFileUploadUseCase, StubFileUseCaseFactory, StubFileWritePort, StubFolderStoragePort, + StubFolderUseCase, StubI18nService, StubIdMappingService, StubSearchUseCase, + StubStorageMediator, StubZipPort, }; /// Factory for the different application components -/// +/// /// This factory centralizes the creation of all application services, /// ensuring the correct initialization order and resolving circular dependencies. pub struct AppServiceFactory { @@ -72,7 +77,7 @@ impl AppServiceFactory { config: AppConfig::default(), } } - + /// Creates a new service factory with custom configuration pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self { Self { @@ -81,97 +86,98 @@ impl AppServiceFactory { config, } } - + /// Gets the configuration pub fn config(&self) -> &AppConfig { &self.config } - + /// Gets the storage path pub fn storage_path(&self) -> &PathBuf { &self.storage_path } - + /// Initializes the core system services pub async fn create_core_services(&self) -> Result { // Path service let path_service = Arc::new(PathService::new(self.storage_path.clone())); - + // File content cache for ultra-fast file serving (hot files in RAM) let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig { - max_file_size: 10 * 1024 * 1024, // 10MB max per file - max_total_size: 512 * 1024 * 1024, // 512MB total cache - max_entries: 10000, // Up to 10k files + max_file_size: 10 * 1024 * 1024, // 10MB max per file + max_total_size: 512 * 1024 * 1024, // 512MB total cache + max_entries: 10000, // Up to 10k files })); tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries"); - + // ID mapping service for folders let folder_id_mapping_path = self.storage_path.join("folder_ids.json"); - let folder_id_mapping_service = Arc::new( - IdMappingService::new(folder_id_mapping_path).await? - ); - + let folder_id_mapping_service = + Arc::new(IdMappingService::new(folder_id_mapping_path).await?); + // ID mapping service for files let file_id_mapping_path = self.storage_path.join("file_ids.json"); - let file_id_mapping_service = Arc::new( - IdMappingService::new(file_id_mapping_path).await? - ); - + let file_id_mapping_service = Arc::new(IdMappingService::new(file_id_mapping_path).await?); + // Optimizer with batch processing and caching - let id_mapping_optimizer = Arc::new( - IdMappingOptimizer::new(folder_id_mapping_service.clone()) - ); - + let id_mapping_optimizer = + Arc::new(IdMappingOptimizer::new(folder_id_mapping_service.clone())); + // Start optimizer cleanup task IdMappingOptimizer::start_cleanup_task(id_mapping_optimizer.clone()); - + // Thumbnail service for thumbnail generation let thumbnail_service = Arc::new( crate::infrastructure::services::thumbnail_service::ThumbnailService::new( &self.storage_path, - 5000, // max 5000 thumbnails in cache - 100 * 1024 * 1024, // max 100MB cache - ) + 5000, // max 5000 thumbnails in cache + 100 * 1024 * 1024, // max 100MB cache + ), ); // Initialize thumbnail directories thumbnail_service.initialize().await?; - + // Write-behind cache for instant uploads of small files - let write_behind_cache = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); - + let write_behind_cache = + crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); + // Chunked upload service for large files (>10MB) let chunked_temp_dir = std::path::PathBuf::from(&self.storage_path).join(".uploads"); let chunked_upload_service = Arc::new( - crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new(chunked_temp_dir) + crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( + chunked_temp_dir, + ), ); - + // Image transcoding service for automatic WebP conversion let image_transcode_service = Arc::new( crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( &self.storage_path, - 2000, // max 2000 transcoded images in cache - 50 * 1024 * 1024, // max 50MB in-memory cache - ) + 2000, // max 2000 transcoded images in cache + 50 * 1024 * 1024, // max 50MB in-memory cache + ), ); image_transcode_service.initialize().await?; - + // Deduplication service for removing duplicate files let dedup_service = Arc::new( - crate::infrastructure::services::dedup_service::DedupService::new(&self.storage_path) + crate::infrastructure::services::dedup_service::DedupService::new(&self.storage_path), ); dedup_service.initialize().await?; - + // Compression service (gzip) let compression_service: Arc = Arc::new( - crate::infrastructure::services::compression_service::GzipCompressionService::new() + crate::infrastructure::services::compression_service::GzipCompressionService::new(), ); - - tracing::info!("Core services initialized: path service, cache manager, file content cache, ID mapping, thumbnails, write-behind cache, chunked upload, image transcode, dedup, compression"); - + + tracing::info!( + "Core services initialized: path service, cache manager, file content cache, ID mapping, thumbnails, write-behind cache, chunked upload, image transcode, dedup, compression" + ); + // NOTE: zip_service requires ApplicationServices (FileRetrievalUseCase, FolderUseCase) which are // created later. It will be set via AppState::with_zip_service() after application services are ready. // For now we use a placeholder that will be replaced. - + Ok(CoreServices { path_service, file_content_cache, @@ -184,18 +190,17 @@ impl AppServiceFactory { image_transcode_service, dedup_service, compression_service, - zip_service: Arc::new(StubZipPort), // Placeholder - replaced after app services init + zip_service: Arc::new(StubZipPort), // Placeholder - replaced after app services init config: self.config.clone(), }) } - + /// Initializes the repository services pub fn create_repository_services(&self, core: &CoreServices) -> RepositoryServices { // Storage mediator - uses stub initially, will be replaced after folder repo is ready - let storage_mediator_stub: Arc = Arc::new( - FileSystemStorageMediator::new_stub() - ); - + let storage_mediator_stub: Arc = + Arc::new(FileSystemStorageMediator::new_stub()); + // Folder repository — implements FolderStoragePort directly let folder_repository = Arc::new(FolderFsRepository::new( self.storage_path.clone(), @@ -203,35 +208,33 @@ impl AppServiceFactory { core.id_mapping_service.clone(), core.path_service.clone(), )); - + // Now create the real storage mediator with the folder repo (as FolderStoragePort) let storage_mediator: Arc = Arc::new(FileSystemStorageMediator::new( folder_repository.clone() as Arc, core.path_service.clone(), - core.id_mapping_optimizer.clone() + core.id_mapping_optimizer.clone(), )); - + // Metadata cache - let metadata_cache = Arc::new( - FileMetadataCache::default_with_config(core.config.clone()) - ); - + let metadata_cache = Arc::new(FileMetadataCache::default_with_config(core.config.clone())); + // Start metadata cache cleanup task let cache_clone = metadata_cache.clone(); tokio::spawn(async move { FileMetadataCache::start_cleanup_task(cache_clone).await; }); - + // Buffer pool for memory optimization let buffer_pool = BufferPool::new(256 * 1024, 50, 120); // 256KB buffers, 50 max, 2 min TTL BufferPool::start_cleaner(buffer_pool.clone()); - + // Parallel file processor let parallel_processor = Arc::new(ParallelFileProcessor::new_with_buffer_pool( core.config.clone(), - buffer_pool.clone() + buffer_pool.clone(), )); - + // Separate file repositories for reading and writing let file_read_repository = Arc::new(FileFsReadRepository::new( self.storage_path.clone(), @@ -242,7 +245,7 @@ impl AppServiceFactory { core.config.clone(), Some(parallel_processor.clone()), )); - + let file_write_repository = Arc::new(FileFsWriteRepository::new( self.storage_path.clone(), storage_mediator.clone(), @@ -252,24 +255,25 @@ impl AppServiceFactory { core.config.clone(), Some(parallel_processor.clone()), )); - + // I18n repository - let i18n_repository = Arc::new(FileSystemI18nService::new( - self.locales_path.clone() - )); - + let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone())); + // Trash repository let trash_repository = if core.config.features.enable_trash { Some(Arc::new(TrashFsRepository::new( self.storage_path.as_path(), core.id_mapping_service.clone(), - )) as Arc) + )) + as Arc< + dyn crate::domain::repositories::trash_repository::TrashRepository, + >) } else { None }; - + tracing::info!("Repository services initialized with parallel processing and buffer pool"); - + RepositoryServices { folder_repository, file_read_repository, @@ -280,7 +284,7 @@ impl AppServiceFactory { trash_repository, } } - + /// Initializes the application services pub fn create_application_services( &self, @@ -289,10 +293,8 @@ impl AppServiceFactory { trash_service: Option>, ) -> ApplicationServices { // Main services - let folder_service = Arc::new(FolderService::new( - repos.folder_repository.clone() - )); - + let folder_service = Arc::new(FolderService::new(repos.folder_repository.clone())); + // Refactored services with all infrastructure ports let file_upload_service = Arc::new(FileUploadService::new_full( repos.file_write_repository.clone(), @@ -300,14 +302,14 @@ impl AppServiceFactory { core.write_behind_cache.clone(), core.dedup_service.clone(), )); - + let file_retrieval_service = Arc::new(FileRetrievalService::new_full( repos.file_read_repository.clone(), core.write_behind_cache.clone(), core.file_content_cache.clone(), core.image_transcode_service.clone(), )); - + // FileManagementService with dedup and trash let file_management_service = Arc::new(FileManagementService::new_full( repos.file_write_repository.clone(), @@ -315,26 +317,24 @@ impl AppServiceFactory { trash_service.clone(), core.dedup_service.clone(), )); - + let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( repos.file_read_repository.clone(), - repos.file_write_repository.clone() + repos.file_write_repository.clone(), )); - - let i18n_service = Arc::new(I18nApplicationService::new( - repos.i18n_repository.clone() - )); - + + let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone())); + // Search service with cache let search_service: Option> = Some(Arc::new(SearchService::new( repos.file_read_repository.clone(), repos.folder_repository.clone(), - 300, // Cache TTL in seconds (5 minutes) + 300, // Cache TTL in seconds (5 minutes) 1000, // Maximum cache entries ))); - + tracing::info!("Application services initialized"); - + ApplicationServices { // Concrete types for handlers that need them folder_service_concrete: folder_service.clone(), @@ -347,12 +347,12 @@ impl AppServiceFactory { i18n_service, trash_service, // Already set via parameter search_service, - share_service: None, // Configured later with create_share_service + share_service: None, // Configured later with create_share_service favorites_service: None, // Configured later with create_favorites_service - recent_service: None, // Configured later with create_recent_service + recent_service: None, // Configured later with create_recent_service } } - + /// Creates the trash service pub async fn create_trash_service( &self, @@ -362,9 +362,9 @@ impl AppServiceFactory { tracing::info!("Trash service is disabled in configuration"); return None; } - + let trash_repo = repos.trash_repository.as_ref()?; - + // Wire ports directly to TrashService — no adapter layer needed let service = Arc::new(TrashService::new( trash_repo.clone(), @@ -373,20 +373,20 @@ impl AppServiceFactory { repos.folder_repository.clone(), self.config.storage.trash_retention_days, )); - + // Initialize cleanup service let cleanup_service = TrashCleanupService::new( service.clone(), trash_repo.clone(), 24, // Run cleanup every 24 hours ); - + cleanup_service.start_cleanup_job().await; tracing::info!("Trash service initialized with daily cleanup schedule"); - + Some(service as Arc) } - + /// Creates the sharing service pub fn create_share_service( &self, @@ -396,11 +396,9 @@ impl AppServiceFactory { tracing::info!("File sharing service is disabled in configuration"); return None; } - - let share_repository = Arc::new(ShareFsRepository::new( - Arc::new(self.config.clone()) - )); - + + let share_repository = Arc::new(ShareFsRepository::new(Arc::new(self.config.clone()))); + // Build a password hasher for share password verification let password_hasher: Arc = Arc::new(crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new()); @@ -412,44 +410,37 @@ impl AppServiceFactory { repos.folder_repository.clone(), password_hasher, )); - + tracing::info!("File sharing service initialized"); Some(service) } - + /// Creates the favorites service (requires database) - pub fn create_favorites_service( - &self, - db_pool: &Arc, - ) -> Arc { + pub fn create_favorites_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( - crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()) + crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()), ); let service = Arc::new(FavoritesService::new(repo)); tracing::info!("Favorites service initialized"); service } - + /// Creates the recent items service (requires database) - pub fn create_recent_service( - &self, - db_pool: &Arc, - ) -> Arc { + pub fn create_recent_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( - crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()) + crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()), ); let service = Arc::new(RecentService::new( - repo, - 50 // Maximum recent items per user + repo, 50, // Maximum recent items per user )); tracing::info!("Recent items service initialized"); service } - + /// Preloads translations pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) { use crate::domain::services::i18n_service::Locale; - + if let Err(e) = i18n_service.load_translations(Locale::English).await { tracing::warn!("Failed to load English translations: {}", e); } @@ -467,11 +458,14 @@ impl AppServiceFactory { } tracing::info!("Translations preloaded"); } - + /// Preloads directories into cache pub async fn preload_cache(&self, metadata_cache: &FileMetadataCache) { tracing::info!("Preloading common directories to warm up cache..."); - if let Ok(count) = metadata_cache.preload_directory(&self.storage_path, true, 1).await { + if let Ok(count) = metadata_cache + .preload_directory(&self.storage_path, true, 1) + .await + { tracing::info!("Preloaded {} directory entries into cache", count); } } @@ -483,13 +477,13 @@ impl AppServiceFactory { db_pool: &Arc, ) -> Arc { let user_repository = Arc::new( - crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()) + crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()), ); let service = Arc::new( crate::application::services::storage_usage_service::StorageUsageService::new( repos.file_read_repository.clone(), user_repository, - ) + ), ); tracing::info!("Storage usage service initialized"); service @@ -521,7 +515,9 @@ impl AppServiceFactory { // 6. Database-dependent services let mut favorites_service: Option> = None; let mut recent_service: Option> = None; - let mut storage_usage_service: Option> = None; + let mut storage_usage_service: Option< + Arc, + > = None; let mut auth_services: Option = None; if let Some(ref pool) = db_pool { @@ -541,7 +537,9 @@ impl AppServiceFactory { &self.config, pool.clone(), Some(apps.folder_service_concrete.clone()), - ).await { + ) + .await + { Ok(services) => { tracing::info!("Authentication services initialized successfully"); auth_services = Some(services); @@ -564,7 +562,7 @@ impl AppServiceFactory { crate::infrastructure::services::zip_service::ZipService::new( apps.file_retrieval_service.clone(), apps.folder_service.clone(), - ) + ), ); let mut core = core; core.zip_service = zip_service; @@ -588,11 +586,11 @@ impl AppServiceFactory { addressbook_use_case: None, contact_use_case: None, }; - + // 10b. Wire admin settings service when auth + DB are available if let (Some(auth_svc), Some(pool)) = (&app_state.auth_service, &db_pool) { let settings_repo = Arc::new( - crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone()) + crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone()), ); let server_base_url = self.config.base_url(); @@ -608,34 +606,50 @@ impl AppServiceFactory { // Hot-reload OIDC from DB settings if configured match admin_svc.load_effective_oidc_config().await { - Ok(eff) if eff.enabled && !eff.issuer_url.is_empty() - && !eff.client_id.is_empty() && !eff.client_secret.is_empty() => + Ok(eff) + if eff.enabled + && !eff.issuer_url.is_empty() + && !eff.client_id.is_empty() + && !eff.client_secret.is_empty() => { let oidc_svc = Arc::new( - crate::infrastructure::services::oidc_service::OidcService::new(eff.clone()) + crate::infrastructure::services::oidc_service::OidcService::new( + eff.clone(), + ), ); auth_svc.auth_application_service.reload_oidc(oidc_svc, eff); tracing::info!("OIDC config loaded from admin settings (database)"); } Ok(_) => { - tracing::info!("No active OIDC config in admin settings — using env vars or defaults"); + tracing::info!( + "No active OIDC config in admin settings — using env vars or defaults" + ); } Err(e) => { - tracing::warn!("Failed to load OIDC settings from database (table may not exist yet): {}", e); + tracing::warn!( + "Failed to load OIDC settings from database (table may not exist yet): {}", + e + ); } } app_state.admin_settings_service = Some(admin_svc); } - + // 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 calendar_repo: Arc< + dyn crate::domain::repositories::calendar_repository::CalendarRepository, + > = 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 event_repo: Arc< + dyn crate::domain::repositories::calendar_event_repository::CalendarEventRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::CalendarEventPgRepository::new( + pool.clone(), + ), ); let calendar_storage = Arc::new( crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new( @@ -644,19 +658,32 @@ impl AppServiceFactory { ) ); let calendar_service = Arc::new( - crate::application::services::calendar_service::CalendarService::new(calendar_storage) + crate::application::services::calendar_service::CalendarService::new( + calendar_storage, + ), ); - app_state.calendar_use_case = Some(calendar_service as Arc); - + 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 address_book_repo: Arc< + dyn crate::domain::repositories::address_book_repository::AddressBookRepository, + > = 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 contact_repo: Arc< + dyn crate::domain::repositories::contact_repository::ContactRepository, + > = 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 group_repo: Arc< + dyn crate::domain::repositories::contact_repository::ContactGroupRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::ContactGroupPgRepository::new( + pool.clone(), + ), ); let contact_storage = Arc::new( crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( @@ -665,9 +692,13 @@ impl AppServiceFactory { group_repo, ) ); - app_state.addressbook_use_case = Some(contact_storage.clone() as Arc); - app_state.contact_use_case = Some(contact_storage as Arc); - + 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"); } @@ -702,7 +733,8 @@ pub struct RepositoryServices { pub i18n_repository: Arc, pub storage_mediator: Arc, pub metadata_cache: Arc, - pub trash_repository: Option>, + pub trash_repository: + Option>, } /// Container for application services @@ -744,11 +776,14 @@ pub struct AppState { pub share_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, - pub storage_usage_service: Option>, + pub storage_usage_service: + Option>, pub calendar_service: Option>, pub contact_service: Option>, - pub calendar_use_case: Option>, - pub addressbook_use_case: Option>, + pub calendar_use_case: + Option>, + pub addressbook_use_case: + Option>, pub contact_use_case: Option>, } @@ -760,19 +795,27 @@ impl Default for AppState { let config = crate::common::config::AppConfig::default(); let path_service = Arc::new( crate::infrastructure::services::path_service::PathService::new( - std::path::PathBuf::from("./storage") - ) + std::path::PathBuf::from("./storage"), + ), ); // Create service instances from the stubs module - let id_mapping_service = Arc::new(StubIdMappingService) as Arc; - let storage_mediator = Arc::new(StubStorageMediator) as Arc; - let i18n_repository = Arc::new(StubI18nService) as Arc; - let folder_service = Arc::new(StubFolderUseCase) as Arc; - let file_upload_service = Arc::new(StubFileUploadUseCase) as Arc; - let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) as Arc; - let file_management_service = Arc::new(StubFileManagementUseCase) as Arc; - let file_use_case_factory = Arc::new(StubFileUseCaseFactory) as Arc; + let id_mapping_service = Arc::new(StubIdMappingService) + as Arc; + let storage_mediator = Arc::new(StubStorageMediator) + as Arc; + let i18n_repository = Arc::new(StubI18nService) + as Arc; + let folder_service = Arc::new(StubFolderUseCase) + as Arc; + let file_upload_service = Arc::new(StubFileUploadUseCase) + as Arc; + let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) + as Arc; + let file_management_service = Arc::new(StubFileManagementUseCase) + as Arc; + let file_use_case_factory = Arc::new(StubFileUseCaseFactory) + as Arc; // Create dummy ID mapping service for files let dummy_file_id_mapping = Arc::new(IdMappingService::dummy()); @@ -787,17 +830,18 @@ impl Default for AppState { &std::path::PathBuf::from("./storage"), 100, 10 * 1024 * 1024, - ) + ), ); // Create dummy write-behind cache - let dummy_write_behind_cache: Arc = crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); + let dummy_write_behind_cache: Arc = + crate::infrastructure::services::write_behind_cache::WriteBehindCache::new(); // Create dummy chunked upload service let dummy_chunked_upload_service: Arc = Arc::new( crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( - std::path::PathBuf::from("./storage/.uploads") - ) + std::path::PathBuf::from("./storage/.uploads"), + ), ); // Create dummy image transcode service @@ -806,14 +850,14 @@ impl Default for AppState { &std::path::PathBuf::from("./storage"), 100, 10 * 1024 * 1024, - ) + ), ); // Create dummy dedup service let dummy_dedup_service: Arc = Arc::new( crate::infrastructure::services::dedup_service::DedupService::new( - &std::path::PathBuf::from("./storage") - ) + &std::path::PathBuf::from("./storage"), + ), ); // Core services using stubs @@ -838,9 +882,12 @@ impl Default for AppState { // Repository services using stubs let repository_services = RepositoryServices { - folder_repository: Arc::new(StubFolderStoragePort) as Arc, - file_read_repository: Arc::new(StubFileReadPort) as Arc, - file_write_repository: Arc::new(StubFileWritePort) as Arc, + folder_repository: Arc::new(StubFolderStoragePort) + as Arc, + file_read_repository: Arc::new(StubFileReadPort) + as Arc, + file_write_repository: Arc::new(StubFileWritePort) + as Arc, i18n_repository, storage_mediator: storage_mediator.clone(), metadata_cache: dummy_metadata_cache, @@ -848,13 +895,16 @@ impl Default for AppState { }; // Dummy concrete services for compatibility - let dummy_folder_storage = Arc::new(StubFolderStoragePort) as Arc; + let dummy_folder_storage = Arc::new(StubFolderStoragePort) + as Arc; let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage)); // Dummy I18nApplicationService - let dummy_i18n_app_service = crate::application::services::i18n_application_service::I18nApplicationService::new( - Arc::new(StubI18nService) as Arc - ); + let dummy_i18n_app_service = + crate::application::services::i18n_application_service::I18nApplicationService::new( + Arc::new(StubI18nService) + as Arc, + ); // Application services using stubs let application_services = ApplicationServices { @@ -866,7 +916,8 @@ impl Default for AppState { file_use_case_factory, i18n_service: Arc::new(dummy_i18n_app_service), trash_service: None, - search_service: Some(Arc::new(StubSearchUseCase) as Arc), + search_service: Some(Arc::new(StubSearchUseCase) + as Arc), share_service: None, favorites_service: None, recent_service: None, @@ -918,12 +969,12 @@ impl AppState { contact_use_case: None, } } - + pub fn with_database(mut self, db_pool: Arc) -> Self { self.db_pool = Some(db_pool); self } - + /// Creates a minimal AppState for route construction. /// /// Uses `Default` stubs for infrastructure services, then overlays the real @@ -931,11 +982,15 @@ impl AppState { /// This keeps `routes.rs` free of any `crate::infrastructure` references. pub fn for_routing( folder_service: Arc, - file_retrieval_service: Arc, + file_retrieval_service: Arc< + dyn crate::application::ports::file_ports::FileRetrievalUseCase, + >, file_upload_service: Arc, file_management_service: Arc, folder_use_case: Arc, - i18n_service: Option>, + i18n_service: Option< + Arc, + >, trash_service: Option>, search_service: Option>, share_service: Option>, @@ -943,98 +998,121 @@ impl AppState { recent_service: Option>, ) -> Self { let mut state = Self::default(); - + // Override application services with real ones state.applications.folder_service_concrete = folder_service.clone(); state.applications.folder_service = folder_use_case; state.applications.file_upload_service = file_upload_service; state.applications.file_retrieval_service = file_retrieval_service.clone(); state.applications.file_management_service = file_management_service; - + if let Some(i18n) = i18n_service { state.applications.i18n_service = i18n; } - + state.applications.trash_service = trash_service.clone(); state.applications.search_service = search_service.clone(); state.applications.share_service = share_service.clone(); state.applications.favorites_service = favorites_service.clone(); state.applications.recent_service = recent_service.clone(); - + // Also set top-level optional services state.trash_service = trash_service; state.share_service = share_service; state.favorites_service = favorites_service; state.recent_service = recent_service; - + // Create real ZipService with the actual file/folder services state.core.zip_service = Arc::new( crate::infrastructure::services::zip_service::ZipService::new( - file_retrieval_service as Arc, - folder_service.clone() as Arc, - ) + file_retrieval_service + as Arc, + folder_service.clone() + as Arc, + ), ); - + state } - + pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self { self.auth_service = Some(auth_services); self } - + pub fn with_trash_service(mut self, trash_service: Arc) -> Self { self.trash_service = Some(trash_service); self } - - pub fn with_share_service(mut self, share_service: Arc) -> Self { + + pub fn with_share_service( + mut self, + share_service: Arc, + ) -> Self { self.share_service = Some(share_service); self } - + pub fn with_favorites_service(mut self, favorites_service: Arc) -> Self { self.favorites_service = Some(favorites_service); self } - + pub fn with_recent_service(mut self, recent_service: Arc) -> Self { self.recent_service = Some(recent_service); self } - - pub fn with_storage_usage_service(mut self, storage_usage_service: Arc) -> Self { + + pub fn with_storage_usage_service( + mut self, + storage_usage_service: Arc, + ) -> Self { self.storage_usage_service = Some(storage_usage_service); self } - - pub fn with_calendar_service(mut self, calendar_service: Arc) -> Self { + + pub fn with_calendar_service( + mut self, + calendar_service: Arc, + ) -> Self { self.calendar_service = Some(calendar_service); self } - - pub fn with_contact_service(mut self, contact_service: Arc) -> Self { + + pub fn with_contact_service( + mut self, + contact_service: Arc, + ) -> Self { self.contact_service = Some(contact_service); self } - - pub fn with_calendar_use_case(mut self, calendar_use_case: Arc) -> 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 { + + 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 { + + 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 } -} \ No newline at end of file +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 0254d7a2..b0892873 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,4 +1,4 @@ -pub mod errors; pub mod config; pub mod di; -pub mod stubs; \ No newline at end of file +pub mod errors; +pub mod stubs; diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 2d4eb5af..0b43707a 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -14,23 +14,25 @@ use bytes::Bytes; use futures::Stream; use crate::application::dtos::file_dto::FileDto; -use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto}; +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; use crate::application::dtos::pagination::{PaginatedResponseDto, PaginationRequestDto}; use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, - UploadStrategy, OptimizedFileContent, + OptimizedFileContent, UploadStrategy, }; use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; use crate::application::ports::outbound::IdMappingPort; -use crate::domain::repositories::folder_repository::FolderRepository; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::zip_ports::ZipPort; use crate::application::services::storage_mediator::{StorageMediator, StorageMediatorError}; use crate::common::errors::DomainError; use crate::domain::entities::file::File; use crate::domain::entities::folder::Folder; +use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale}; use crate::domain::services::path_service::StoragePath; @@ -89,10 +91,7 @@ pub struct StubIdMappingService; #[async_trait] impl IdMappingPort for StubIdMappingService { - async fn get_or_create_id( - &self, - _path: &StoragePath, - ) -> Result { + async fn get_or_create_id(&self, _path: &StoragePath) -> Result { Ok("dummy-id".to_string()) } @@ -100,11 +99,7 @@ impl IdMappingPort for StubIdMappingService { Ok(StoragePath::from_string("/")) } - async fn update_path( - &self, - _id: &str, - _new_path: &StoragePath, - ) -> Result<(), DomainError> { + async fn update_path(&self, _id: &str, _new_path: &StoragePath) -> Result<(), DomainError> { Ok(()) } @@ -125,10 +120,7 @@ pub struct StubStorageMediator; #[async_trait] impl StorageMediator for StubStorageMediator { - async fn get_folder_path( - &self, - _folder_id: &str, - ) -> Result { + async fn get_folder_path(&self, _folder_id: &str) -> Result { Ok(PathBuf::from("/tmp")) } @@ -139,19 +131,13 @@ impl StorageMediator for StubStorageMediator { Ok(StoragePath::root()) } - async fn get_folder( - &self, - _folder_id: &str, - ) -> Result { + async fn get_folder(&self, _folder_id: &str) -> Result { Err(StorageMediatorError::NotFound( "Stub not implemented".to_string(), )) } - async fn file_exists_at_path( - &self, - _path: &Path, - ) -> Result { + async fn file_exists_at_path(&self, _path: &Path) -> Result { Ok(false) } @@ -162,10 +148,7 @@ impl StorageMediator for StubStorageMediator { Ok(false) } - async fn folder_exists_at_path( - &self, - _path: &Path, - ) -> Result { + async fn folder_exists_at_path(&self, _path: &Path) -> Result { Ok(false) } @@ -184,10 +167,7 @@ impl StorageMediator for StubStorageMediator { PathBuf::from("/tmp") } - async fn ensure_directory( - &self, - _path: &Path, - ) -> Result<(), StorageMediatorError> { + async fn ensure_directory(&self, _path: &Path) -> Result<(), StorageMediatorError> { Ok(()) } @@ -236,10 +216,7 @@ impl FileReadPort for StubFileReadPort { Ok(File::default()) } - async fn list_files( - &self, - _folder_id: Option<&str>, - ) -> Result, DomainError> { + async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { Ok(Vec::new()) } @@ -314,11 +291,7 @@ impl FileWritePort for StubFileWritePort { Ok(File::default()) } - async fn rename_file( - &self, - _file_id: &str, - _new_name: &str, - ) -> Result { + async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { Ok(File::default()) } @@ -348,7 +321,11 @@ impl FileWritePort for StubFileWritePort { Ok(()) } - async fn restore_from_trash(&self, _file_id: &str, _original_path: &str) -> Result<(), DomainError> { + async fn restore_from_trash( + &self, + _file_id: &str, + _original_path: &str, + ) -> Result<(), DomainError> { Ok(()) } @@ -377,17 +354,11 @@ impl FolderRepository for StubFolderStoragePort { Ok(Folder::default()) } - async fn get_folder_by_path( - &self, - _storage_path: &StoragePath, - ) -> Result { + async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> Result { Ok(Folder::default()) } - async fn list_folders( - &self, - _parent_id: Option<&str>, - ) -> Result, DomainError> { + async fn list_folders(&self, _parent_id: Option<&str>) -> Result, DomainError> { Ok(Vec::new()) } @@ -401,11 +372,7 @@ impl FolderRepository for StubFolderStoragePort { Ok((Vec::new(), Some(0))) } - async fn rename_folder( - &self, - _id: &str, - _new_name: String, - ) -> Result { + async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { Ok(Folder::default()) } @@ -421,17 +388,11 @@ impl FolderRepository for StubFolderStoragePort { Ok(()) } - async fn folder_exists( - &self, - _storage_path: &StoragePath, - ) -> Result { + async fn folder_exists(&self, _storage_path: &StoragePath) -> Result { Ok(false) } - async fn get_folder_path( - &self, - _id: &str, - ) -> Result { + async fn get_folder_path(&self, _id: &str) -> Result { Ok(StoragePath::from_string("/")) } @@ -439,7 +400,11 @@ impl FolderRepository for StubFolderStoragePort { Ok(()) } - async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> { + async fn restore_from_trash( + &self, + _folder_id: &str, + _original_path: &str, + ) -> Result<(), DomainError> { Ok(()) } @@ -481,10 +446,7 @@ pub struct StubFolderUseCase; #[async_trait] impl FolderUseCase for StubFolderUseCase { - async fn create_folder( - &self, - _dto: CreateFolderDto, - ) -> Result { + async fn create_folder(&self, _dto: CreateFolderDto) -> Result { Ok(FolderDto::default()) } @@ -492,17 +454,11 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn get_folder_by_path( - &self, - _path: &str, - ) -> Result { + async fn get_folder_by_path(&self, _path: &str) -> Result { Ok(FolderDto::default()) } - async fn list_folders( - &self, - _parent_id: Option<&str>, - ) -> Result, DomainError> { + async fn list_folders(&self, _parent_id: Option<&str>) -> Result, DomainError> { Ok(Vec::new()) } @@ -522,11 +478,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn move_folder( - &self, - _id: &str, - _dto: MoveFolderDto, - ) -> Result { + async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result { Ok(FolderDto::default()) } @@ -564,7 +516,13 @@ impl FileUploadUseCase for StubFileUploadUseCase { Ok((FileDto::default(), UploadStrategy::Buffered)) } - async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result { + async fn create_file( + &self, + _parent_path: &str, + _filename: &str, + _content: &[u8], + _content_type: &str, + ) -> Result { Ok(FileDto::default()) } @@ -585,10 +543,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(FileDto::default()) } - async fn list_files( - &self, - _folder_id: Option<&str>, - ) -> Result, DomainError> { + async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { Ok(Vec::new()) } @@ -610,11 +565,14 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { _accept_webp: bool, _prefer_original: bool, ) -> Result<(FileDto, OptimizedFileContent), DomainError> { - Ok((FileDto::default(), OptimizedFileContent::Bytes { - data: Bytes::new(), - mime_type: String::new(), - was_transcoded: false, - })) + Ok(( + FileDto::default(), + OptimizedFileContent::Bytes { + data: Bytes::new(), + mime_type: String::new(), + was_transcoded: false, + }, + )) } async fn get_file_range_stream( @@ -648,11 +606,7 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn rename_file( - &self, - _file_id: &str, - _new_name: &str, - ) -> Result { + async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { Ok(FileDto::default()) } @@ -660,11 +614,7 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(()) } - async fn delete_with_cleanup( - &self, - _id: &str, - _user_id: &str, - ) -> Result { + async fn delete_with_cleanup(&self, _id: &str, _user_id: &str) -> Result { Ok(false) } } @@ -697,10 +647,7 @@ pub struct StubSearchUseCase; #[async_trait] impl SearchUseCase for StubSearchUseCase { - async fn search( - &self, - _criteria: SearchCriteriaDto, - ) -> Result { + async fn search(&self, _criteria: SearchCriteriaDto) -> Result { Ok(SearchResultsDto::empty()) } @@ -713,7 +660,9 @@ impl SearchUseCase for StubSearchUseCase { // MetadataCachePort // --------------------------------------------------------------------------- -use crate::application::ports::cache_ports::{MetadataCachePort, CachedMetadataDto, ContentCachePort}; +use crate::application::ports::cache_ports::{ + CachedMetadataDto, ContentCachePort, MetadataCachePort, +}; pub struct StubMetadataCachePort; diff --git a/src/domain/entities/calendar.rs b/src/domain/entities/calendar.rs index db6ea40c..f870304d 100644 --- a/src/domain/entities/calendar.rs +++ b/src/domain/entities/calendar.rs @@ -1,25 +1,24 @@ +use chrono::{DateTime, Utc}; /** * Calendar Entity - * + * * This module defines the Calendar entity, which represents a calendar in the CalDAV * implementation. Calendars contain calendar events and are owned by users. - * + * * Calendars have properties such as name, color, and description, and they serve as * containers for calendar events. Each calendar belongs to a specific user and can * have custom properties. */ - use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::common::errors::{DomainError, ErrorKind, Result}; // Re-export entity errors from the centralized module pub use super::entity_errors::CalendarError; /** * Calendar entity. - * + * * Represents a calendar container that can hold multiple calendar events. * Each calendar is owned by a user and has properties like name, color, and description. */ @@ -27,25 +26,25 @@ pub use super::entity_errors::CalendarError; pub struct Calendar { /// Unique identifier for the calendar id: Uuid, - + /// Display name of the calendar name: String, - + /// ID of the user who owns this calendar owner_id: String, - + /// Optional description of the calendar description: Option, - + /// Optional color code for UI display (hex format #RRGGBB) color: Option, - + /// Time when the calendar was created created_at: DateTime, - + /// Time when the calendar was last modified updated_at: DateTime, - + /// Optional list of custom properties (for extended CalDAV support) custom_properties: std::collections::HashMap, } @@ -53,7 +52,7 @@ pub struct Calendar { impl Calendar { /** * Creates a new calendar with the given properties. - * + * * @param name Display name of the calendar * @param owner_id ID of the user who owns this calendar * @param description Optional description of the calendar @@ -74,7 +73,7 @@ impl Calendar { "Calendar name cannot be empty", )); } - + if owner_id.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, @@ -82,7 +81,7 @@ impl Calendar { "Owner ID cannot be empty", )); } - + // Validate color format if provided (#RRGGBB) if let Some(ref color_str) = color { if !color_str.starts_with('#') || color_str.len() != 7 { @@ -92,7 +91,7 @@ impl Calendar { "Color must be in #RRGGBB format", )); } - + // Check if remaining characters are valid hex if color_str[1..].chars().any(|c| !c.is_ascii_hexdigit()) { return Err(DomainError::new( @@ -102,9 +101,9 @@ impl Calendar { )); } } - + let now = Utc::now(); - + Ok(Self { id: Uuid::new_v4(), name, @@ -116,11 +115,11 @@ impl Calendar { custom_properties: std::collections::HashMap::new(), }) } - + /** * Creates a calendar with specific ID and timestamps. * Typically used when reconstructing from storage. - * + * * @param id Unique identifier for the calendar * @param name Display name of the calendar * @param owner_id ID of the user who owns this calendar @@ -147,7 +146,7 @@ impl Calendar { "Calendar name cannot be empty", )); } - + if owner_id.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, @@ -155,7 +154,7 @@ impl Calendar { "Owner ID cannot be empty", )); } - + Ok(Self { id, name, @@ -167,59 +166,59 @@ impl Calendar { custom_properties: std::collections::HashMap::new(), }) } - + // Getters - + /// Returns the calendar's unique identifier pub fn id(&self) -> &Uuid { &self.id } - + /// Returns the calendar's display name pub fn name(&self) -> &str { &self.name } - + /// Returns the ID of the user who owns this calendar pub fn owner_id(&self) -> &str { &self.owner_id } - + /// Returns the calendar's description, if any pub fn description(&self) -> Option<&str> { self.description.as_deref() } - + /// Returns the calendar's color code, if any pub fn color(&self) -> Option<&str> { self.color.as_deref() } - + /// Returns the time when the calendar was created pub fn created_at(&self) -> &DateTime { &self.created_at } - + /// Returns the time when the calendar was last modified pub fn updated_at(&self) -> &DateTime { &self.updated_at } - + /// Returns a custom property value by name, if it exists pub fn custom_property(&self, name: &str) -> Option<&str> { self.custom_properties.get(name).map(|s| s.as_str()) } - + /// Returns all custom properties pub fn custom_properties(&self) -> &std::collections::HashMap { &self.custom_properties } - + // Setters and Mutators - + /** * Updates the calendar's name. - * + * * @param name New display name for the calendar * @return Result indicating success or containing a domain error */ @@ -231,25 +230,25 @@ impl Calendar { "Calendar name cannot be empty", )); } - + self.name = name; self.updated_at = Utc::now(); Ok(()) } - + /** * Updates the calendar's description. - * + * * @param description New description for the calendar */ pub fn update_description(&mut self, description: Option) { self.description = description; self.updated_at = Utc::now(); } - + /** * Updates the calendar's color. - * + * * @param color New color code for the calendar * @return Result indicating success or containing a domain error */ @@ -263,7 +262,7 @@ impl Calendar { "Color must be in #RRGGBB format", )); } - + // Check if remaining characters are valid hex if color_str[1..].chars().any(|c| !c.is_ascii_hexdigit()) { return Err(DomainError::new( @@ -273,15 +272,15 @@ impl Calendar { )); } } - + self.color = color; self.updated_at = Utc::now(); Ok(()) } - + /** * Sets a custom property for extended CalDAV support. - * + * * @param name Name of the property * @param value Value of the property */ @@ -289,10 +288,10 @@ impl Calendar { self.custom_properties.insert(name, value); self.updated_at = Utc::now(); } - + /** * Removes a custom property. - * + * * @param name Name of the property to remove * @return true if the property was removed, false if it didn't exist */ @@ -303,17 +302,17 @@ impl Calendar { } result } - + /** * Checks if this calendar belongs to the specified user. - * + * * @param user_id ID of the user to check ownership against * @return true if the calendar belongs to the user, false otherwise */ pub fn belongs_to(&self, user_id: &str) -> bool { self.owner_id == user_id } - + /** * Updates the last modification time of the calendar to now. * Called when calendar events are added, modified, or removed. @@ -321,4 +320,4 @@ impl Calendar { pub fn touch(&mut self) { self.updated_at = Utc::now(); } -} \ No newline at end of file +} diff --git a/src/domain/entities/calendar_event.rs b/src/domain/entities/calendar_event.rs index 24977441..5c88d7e9 100644 --- a/src/domain/entities/calendar_event.rs +++ b/src/domain/entities/calendar_event.rs @@ -1,25 +1,24 @@ +use chrono::{DateTime, Duration, TimeZone, Utc}; /** * Calendar Event Entity - * + * * This module defines the CalendarEvent entity, which represents an event or * appointment in a calendar, following the iCalendar (RFC 5545) specification. - * + * * Calendar events have properties like summary, description, location, start/end times, * and can include recurrence rules for repeating events. Each event belongs to a * specific calendar and stores its complete iCalendar representation. */ - use uuid::Uuid; -use chrono::{DateTime, Utc, Duration, TimeZone}; -use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::common::errors::{DomainError, ErrorKind, Result}; // Re-export entity errors from the centralized module pub use super::entity_errors::CalendarEventError; /** * CalendarEvent entity. - * + * * Represents a calendar event or appointment that can be synced via CalDAV. * Follows the iCalendar format (RFC 5545) for compatibility with CalDAV clients. */ @@ -27,40 +26,40 @@ pub use super::entity_errors::CalendarEventError; pub struct CalendarEvent { /// Unique identifier for the event id: Uuid, - + /// ID of the calendar this event belongs to calendar_id: Uuid, - + /// Short summary/title of the event summary: String, - + /// Detailed description of the event (optional) description: Option, - + /// Location of the event (optional) location: Option, - + /// Start time of the event start_time: DateTime, - + /// End time of the event end_time: DateTime, - + /// Whether this is an all-day event all_day: bool, - + /// Recurrence rule in iCalendar RRULE format (optional) rrule: Option, - + /// Unique identifier in iCalendar format (used for CalDAV sync) ical_uid: String, - + /// Complete iCalendar data (VEVENT component) ical_data: String, - + /// Time when the event was created created_at: DateTime, - + /// Time when the event was last modified updated_at: DateTime, } @@ -68,7 +67,7 @@ pub struct CalendarEvent { impl CalendarEvent { /** * Creates a new calendar event with the given properties. - * + * * @param calendar_id ID of the calendar this event belongs to * @param summary Short summary/title of the event * @param description Detailed description of the event (optional) @@ -99,7 +98,7 @@ impl CalendarEvent { "Event summary cannot be empty", )); } - + if end_time < start_time { return Err(DomainError::new( ErrorKind::InvalidInput, @@ -107,17 +106,18 @@ impl CalendarEvent { "End time cannot be before start time", )); } - + // Validate RRULE if provided (basic validation) if let Some(ref rule) = rrule - && !rule.starts_with("FREQ=") { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "CalendarEvent", - "Recurrence rule must start with FREQ=", - )); - } - + && !rule.starts_with("FREQ=") + { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Recurrence rule must start with FREQ=", + )); + } + // Validate iCalendar data (basic validation) if !ical_data.contains("BEGIN:VEVENT") || !ical_data.contains("END:VEVENT") { return Err(DomainError::new( @@ -126,9 +126,9 @@ impl CalendarEvent { "iCalendar data must contain a VEVENT component", )); } - + let now = Utc::now(); - + Ok(Self { id: Uuid::new_v4(), calendar_id, @@ -145,11 +145,11 @@ impl CalendarEvent { updated_at: now, }) } - + /** * Creates a calendar event with specific ID and timestamps. * Typically used when reconstructing from storage. - * + * * @param id Unique identifier for the event * @param calendar_id ID of the calendar this event belongs to * @param summary Short summary/title of the event @@ -188,7 +188,7 @@ impl CalendarEvent { "Event summary cannot be empty", )); } - + if end_time < start_time { return Err(DomainError::new( ErrorKind::InvalidInput, @@ -196,7 +196,7 @@ impl CalendarEvent { "End time cannot be before start time", )); } - + Ok(Self { id, calendar_id, @@ -213,11 +213,11 @@ impl CalendarEvent { updated_at, }) } - + /** * Creates a calendar event from an iCalendar VEVENT component. * Parses the iCalendar data to extract event properties. - * + * * @param calendar_id ID of the calendar this event belongs to * @param ical_data Complete iCalendar data (VEVENT component) * @return Result containing the new CalendarEvent or a domain error @@ -225,58 +225,63 @@ impl CalendarEvent { pub fn from_ical(calendar_id: Uuid, ical_data: String) -> Result { // This implementation would require a proper iCalendar parser // For brevity, we're using a simplified version here - + // Extract required fields from iCalendar data - let summary = Self::extract_ical_property(&ical_data, "SUMMARY") - .ok_or_else(|| DomainError::new( + let summary = Self::extract_ical_property(&ical_data, "SUMMARY").ok_or_else(|| { + DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Missing SUMMARY in iCalendar data", - ))?; - - let dtstart = Self::extract_ical_property(&ical_data, "DTSTART") - .ok_or_else(|| DomainError::new( + ) + })?; + + let dtstart = Self::extract_ical_property(&ical_data, "DTSTART").ok_or_else(|| { + DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Missing DTSTART in iCalendar data", - ))?; - - let dtend = Self::extract_ical_property(&ical_data, "DTEND") - .ok_or_else(|| DomainError::new( + ) + })?; + + let dtend = Self::extract_ical_property(&ical_data, "DTEND").ok_or_else(|| { + DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Missing DTEND in iCalendar data", - ))?; - + ) + })?; + // Parse dates (simplified) - let start_time = Self::parse_ical_datetime(&dtstart) - .map_err(|e| DomainError::new( + let start_time = Self::parse_ical_datetime(&dtstart).map_err(|e| { + DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", format!("Invalid DTSTART: {}", e), - ))?; - - let end_time = Self::parse_ical_datetime(&dtend) - .map_err(|e| DomainError::new( + ) + })?; + + let end_time = Self::parse_ical_datetime(&dtend).map_err(|e| { + DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", format!("Invalid DTEND: {}", e), - ))?; - + ) + })?; + // Determine if all-day event (simplified check) let all_day = dtstart.contains("VALUE=DATE") && !dtstart.contains("T"); - + // Extract optional fields let description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); let location = Self::extract_ical_property(&ical_data, "LOCATION"); let rrule = Self::extract_ical_property(&ical_data, "RRULE"); - + // Extract UID or generate a new one let ical_uid = Self::extract_ical_property(&ical_data, "UID") .unwrap_or_else(|| Uuid::new_v4().to_string()); - + let now = Utc::now(); - + Ok(Self { id: Uuid::new_v4(), calendar_id, @@ -293,84 +298,84 @@ impl CalendarEvent { updated_at: now, }) } - + // Getters - + /// Returns the event's unique identifier pub fn id(&self) -> &Uuid { &self.id } - + /// Returns the ID of the calendar this event belongs to pub fn calendar_id(&self) -> &Uuid { &self.calendar_id } - + /// Returns the event's summary/title pub fn summary(&self) -> &str { &self.summary } - + /// Returns the event's description, if any pub fn description(&self) -> Option<&str> { self.description.as_deref() } - + /// Returns the event's location, if any pub fn location(&self) -> Option<&str> { self.location.as_deref() } - + /// Returns the event's start time pub fn start_time(&self) -> &DateTime { &self.start_time } - + /// Returns the event's end time pub fn end_time(&self) -> &DateTime { &self.end_time } - + /// Returns whether this is an all-day event pub fn all_day(&self) -> bool { self.all_day } - + /// Returns the event's recurrence rule, if any pub fn rrule(&self) -> Option<&str> { self.rrule.as_deref() } - + /// Returns the event's iCalendar UID pub fn ical_uid(&self) -> &str { &self.ical_uid } - + /// Returns the complete iCalendar data for the event pub fn ical_data(&self) -> &str { &self.ical_data } - + /// Returns the time when the event was created pub fn created_at(&self) -> &DateTime { &self.created_at } - + /// Returns the time when the event was last modified pub fn updated_at(&self) -> &DateTime { &self.updated_at } - + /// Returns the duration of the event pub fn duration(&self) -> Duration { self.end_time - self.start_time } - + // Setters and Mutators - + /** * Updates the event's summary/title. - * + * * @param summary New summary/title for the event * @return Result indicating success or containing a domain error */ @@ -382,58 +387,62 @@ impl CalendarEvent { "Event summary cannot be empty", )); } - + // Clone the summary before updating the struct let summary_clone = summary.clone(); self.summary = summary; self.updated_at = Utc::now(); - + // Update iCalendar data using the cloned value self.update_ical_property("SUMMARY", &summary_clone); - + Ok(()) } - + /** * Updates the event's description. - * + * * @param description New description for the event */ pub fn update_description(&mut self, description: Option) { self.description = description.clone(); self.updated_at = Utc::now(); - + // Update iCalendar data match description { Some(desc) => self.update_ical_property("DESCRIPTION", &desc), None => self.remove_ical_property("DESCRIPTION"), } } - + /** * Updates the event's location. - * + * * @param location New location for the event */ pub fn update_location(&mut self, location: Option) { self.location = location.clone(); self.updated_at = Utc::now(); - + // Update iCalendar data match location { Some(loc) => self.update_ical_property("LOCATION", &loc), None => self.remove_ical_property("LOCATION"), } } - + /** * Updates the event's start and end times. - * + * * @param start_time New start time for the event * @param end_time New end time for the event * @return Result indicating success or containing a domain error */ - pub fn update_time_range(&mut self, start_time: DateTime, end_time: DateTime) -> Result<()> { + pub fn update_time_range( + &mut self, + start_time: DateTime, + end_time: DateTime, + ) -> Result<()> { if end_time < start_time { return Err(DomainError::new( ErrorKind::InvalidInput, @@ -441,89 +450,90 @@ impl CalendarEvent { "End time cannot be before start time", )); } - + self.start_time = start_time; self.end_time = end_time; self.updated_at = Utc::now(); - + // Update iCalendar data let start_str = if self.all_day { format!("{}T000000Z", start_time.format("%Y%m%d")) } else { format!("{}", start_time.format("%Y%m%dT%H%M%SZ")) }; - + let end_str = if self.all_day { format!("{}T000000Z", end_time.format("%Y%m%d")) } else { format!("{}", end_time.format("%Y%m%dT%H%M%SZ")) }; - + self.update_ical_property("DTSTART", &start_str); self.update_ical_property("DTEND", &end_str); - + Ok(()) } - + /** * Updates whether this is an all-day event. - * + * * @param all_day Whether this is an all-day event */ pub fn update_all_day(&mut self, all_day: bool) { self.all_day = all_day; self.updated_at = Utc::now(); - + // Update iCalendar data let start_str = if all_day { format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d")) } else { format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ")) }; - + let end_str = if all_day { format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d")) } else { format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ")) }; - + self.update_ical_property("DTSTART", &start_str); self.update_ical_property("DTEND", &end_str); } - + /** * Updates the event's recurrence rule. - * + * * @param rrule New recurrence rule for the event * @return Result indicating success or containing a domain error */ pub fn update_rrule(&mut self, rrule: Option) -> Result<()> { // Validate RRULE if provided (basic validation) if let Some(ref rule) = rrule - && !rule.starts_with("FREQ=") { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "CalendarEvent", - "Recurrence rule must start with FREQ=", - )); - } - + && !rule.starts_with("FREQ=") + { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Recurrence rule must start with FREQ=", + )); + } + self.rrule = rrule.clone(); self.updated_at = Utc::now(); - + // Update iCalendar data match rrule { Some(rule) => self.update_ical_property("RRULE", &rule), None => self.remove_ical_property("RRULE"), } - + Ok(()) } - + /** * Updates the complete iCalendar data for the event. * Also updates the event properties based on the new iCalendar data. - * + * * @param ical_data New iCalendar data for the event * @return Result indicating success or containing a domain error */ @@ -536,55 +546,57 @@ impl CalendarEvent { "iCalendar data must contain a VEVENT component", )); } - + // Extract and update properties from iCalendar data if let Some(summary) = Self::extract_ical_property(&ical_data, "SUMMARY") { self.summary = summary; } - + self.description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); self.location = Self::extract_ical_property(&ical_data, "LOCATION"); - + if let Some(dtstart) = Self::extract_ical_property(&ical_data, "DTSTART") - && let Ok(start_time) = Self::parse_ical_datetime(&dtstart) { - self.start_time = start_time; - } - + && let Ok(start_time) = Self::parse_ical_datetime(&dtstart) + { + self.start_time = start_time; + } + if let Some(dtend) = Self::extract_ical_property(&ical_data, "DTEND") - && let Ok(end_time) = Self::parse_ical_datetime(&dtend) { - self.end_time = end_time; - } - + && let Ok(end_time) = Self::parse_ical_datetime(&dtend) + { + self.end_time = end_time; + } + // Update all-day status based on DTSTART if let Some(dtstart) = Self::extract_ical_property(&ical_data, "DTSTART") { self.all_day = dtstart.contains("VALUE=DATE") && !dtstart.contains("T"); } - + self.rrule = Self::extract_ical_property(&ical_data, "RRULE"); - + if let Some(uid) = Self::extract_ical_property(&ical_data, "UID") { self.ical_uid = uid; } - + self.ical_data = ical_data; self.updated_at = Utc::now(); - + Ok(()) } - + /** * Checks if this event belongs to the specified calendar. - * + * * @param calendar_id ID of the calendar to check against * @return true if the event belongs to the calendar, false otherwise */ pub fn belongs_to_calendar(&self, calendar_id: &Uuid) -> bool { self.calendar_id == *calendar_id } - + /** * Checks if this event occurs within the specified time range. - * + * * @param start Start of the time range to check * @param end End of the time range to check * @return true if the event occurs within the range, false otherwise @@ -594,20 +606,20 @@ impl CalendarEvent { if self.start_time <= *end && self.end_time >= *start { return true; } - + // If event has recurrence, check if any recurrence occurs in range // Note: A full implementation would need a proper recurrence rule parser if let Some(rrule) = &self.rrule { // Simplified check for demonstration // A real implementation would need to generate recurrence instances // and check if any fall within the range - + // For now, we'll just check if the recurrence hasn't ended // or if it ended after the start of our range if let Some(until_pos) = rrule.find("UNTIL=") { let until_start = until_pos + 6; // "UNTIL=" is 6 chars if let Some(until_end) = rrule[until_start..].find(';') { - let until_str = &rrule[until_start..until_start+until_end]; + let until_str = &rrule[until_start..until_start + until_end]; if let Ok(until_date) = Self::parse_ical_datetime(until_str) { return until_date >= *start; } @@ -623,15 +635,15 @@ impl CalendarEvent { return true; } } - + false } - + // Helper methods for iCalendar operations - + /** * Extracts a property value from iCalendar data. - * + * * @param ical_data The iCalendar data to search in * @param property_name The name of the property to extract * @return Option containing the property value if found @@ -640,33 +652,34 @@ impl CalendarEvent { // Find the property in the iCalendar data let search_str = format!("\n{}:", property_name); let search_str_alt = format!("\r\n{}:", property_name); - - let pos = ical_data.find(&search_str) + + let pos = ical_data + .find(&search_str) .or_else(|| ical_data.find(&search_str_alt)); - + if let Some(pos) = pos { // Find the start of the value let value_start = pos + search_str.len(); - + // Find the end of the value (next line or end of string) let value_end = ical_data[value_start..] .find('\n') .map(|p| value_start + p) .unwrap_or_else(|| ical_data.len()); - + // Extract and return the value let value = ical_data[value_start..value_end].trim(); if !value.is_empty() { return Some(value.to_string()); } } - + None } - + /** * Parses an iCalendar datetime string into a DateTime object. - * + * * @param datetime The iCalendar datetime string to parse * @return Result containing the parsed DateTime or an error */ @@ -677,40 +690,49 @@ impl CalendarEvent { if date_str.len() != 8 { return Err("Invalid date format".to_string()); } - - let year = date_str[0..4].parse::() + + let year = date_str[0..4] + .parse::() .map_err(|_| "Invalid year".to_string())?; - let month = date_str[4..6].parse::() + let month = date_str[4..6] + .parse::() .map_err(|_| "Invalid month".to_string())?; - let day = date_str[6..8].parse::() + let day = date_str[6..8] + .parse::() .map_err(|_| "Invalid day".to_string())?; - + return match chrono::NaiveDate::from_ymd_opt(year, month, day) { Some(date) => Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())), None => Err("Invalid date components".to_string()), }; } - + // Handle standard UTC format (20230101T120000Z) let datetime_str = datetime.split(':').next_back().unwrap_or(datetime); if datetime_str.len() < 15 || !datetime_str.ends_with('Z') { return Err("Invalid datetime format".to_string()); } - - let year = datetime_str[0..4].parse::() + + let year = datetime_str[0..4] + .parse::() .map_err(|_| "Invalid year".to_string())?; - let month = datetime_str[4..6].parse::() + let month = datetime_str[4..6] + .parse::() .map_err(|_| "Invalid month".to_string())?; - let day = datetime_str[6..8].parse::() + let day = datetime_str[6..8] + .parse::() .map_err(|_| "Invalid day".to_string())?; - - let hour = datetime_str[9..11].parse::() + + let hour = datetime_str[9..11] + .parse::() .map_err(|_| "Invalid hour".to_string())?; - let minute = datetime_str[11..13].parse::() + let minute = datetime_str[11..13] + .parse::() .map_err(|_| "Invalid minute".to_string())?; - let second = datetime_str[13..15].parse::() + let second = datetime_str[13..15] + .parse::() .map_err(|_| "Invalid second".to_string())?; - + match chrono::NaiveDate::from_ymd_opt(year, month, day) { Some(date) => match date.and_hms_opt(hour, minute, second) { Some(datetime) => Ok(Utc.from_utc_datetime(&datetime)), @@ -719,70 +741,76 @@ impl CalendarEvent { None => Err("Invalid date components".to_string()), } } - + /** * Updates an iCalendar property in the event's iCalendar data. - * + * * @param property_name The name of the property to update * @param value The new value for the property */ fn update_ical_property(&mut self, property_name: &str, value: &str) { let search_str = format!("\n{}:", property_name); let search_str_alt = format!("\r\n{}:", property_name); - + // Check if property exists - let pos = self.ical_data.find(&search_str) + let pos = self + .ical_data + .find(&search_str) .or_else(|| self.ical_data.find(&search_str_alt)); - + if let Some(pos) = pos { // Find the start of the value let value_start = pos + search_str.len(); - + // Find the end of the value (next line or end of string) let value_end = self.ical_data[value_start..] .find('\n') .map(|p| value_start + p) .unwrap_or_else(|| self.ical_data.len()); - + // Replace the value let before = &self.ical_data[..value_start]; let after = &self.ical_data[value_end..]; self.ical_data = format!("{}{}{}", before, value, after); } else { // Property doesn't exist, add it before END:VEVENT - let end_pos = self.ical_data.find("END:VEVENT") + let end_pos = self + .ical_data + .find("END:VEVENT") .unwrap_or(self.ical_data.len()); - + let before = &self.ical_data[..end_pos]; let after = &self.ical_data[end_pos..]; self.ical_data = format!("{}{}:{}\n{}", before, property_name, value, after); } } - + /** * Removes an iCalendar property from the event's iCalendar data. - * + * * @param property_name The name of the property to remove */ fn remove_ical_property(&mut self, property_name: &str) { let search_str = format!("\n{}:", property_name); let search_str_alt = format!("\r\n{}:", property_name); - + // Check if property exists - let pos = self.ical_data.find(&search_str) + let pos = self + .ical_data + .find(&search_str) .or_else(|| self.ical_data.find(&search_str_alt)); - + if let Some(pos) = pos { // Find the end of the value (next line or end of string) let value_end = self.ical_data[pos + 1..] .find('\n') .map(|p| pos + 1 + p) .unwrap_or_else(|| self.ical_data.len()); - + // Remove the property let before = &self.ical_data[..pos]; let after = &self.ical_data[value_end..]; self.ical_data = format!("{}{}", before, after); } } -} \ No newline at end of file +} diff --git a/src/domain/entities/contact.rs b/src/domain/entities/contact.rs index a81730fc..8a5a660c 100644 --- a/src/domain/entities/contact.rs +++ b/src/domain/entities/contact.rs @@ -46,25 +46,64 @@ impl AddressBook { created_at: DateTime, updated_at: DateTime, ) -> Self { - Self { id, name, owner_id, description, color, is_public, created_at, updated_at } + Self { + id, + name, + owner_id, + description, + color, + is_public, + created_at, + updated_at, + } } // --- Getters --- - pub fn id(&self) -> &Uuid { &self.id } - pub fn name(&self) -> &str { &self.name } - pub fn owner_id(&self) -> &str { &self.owner_id } - pub fn description(&self) -> Option<&str> { self.description.as_deref() } - pub fn color(&self) -> Option<&str> { self.color.as_deref() } - pub fn is_public(&self) -> bool { self.is_public } - pub fn created_at(&self) -> &DateTime { &self.created_at } - pub fn updated_at(&self) -> &DateTime { &self.updated_at } + pub fn id(&self) -> &Uuid { + &self.id + } + pub fn name(&self) -> &str { + &self.name + } + pub fn owner_id(&self) -> &str { + &self.owner_id + } + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + pub fn color(&self) -> Option<&str> { + self.color.as_deref() + } + pub fn is_public(&self) -> bool { + self.is_public + } + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } // --- Setters for mutable operations --- - pub fn set_name(&mut self, name: String) { self.name = name; self.updated_at = Utc::now(); } - pub fn set_description(&mut self, description: Option) { self.description = description; self.updated_at = Utc::now(); } - pub fn set_color(&mut self, color: Option) { self.color = color; self.updated_at = Utc::now(); } - pub fn set_is_public(&mut self, is_public: bool) { self.is_public = is_public; self.updated_at = Utc::now(); } - pub fn set_updated_at(&mut self, updated_at: DateTime) { self.updated_at = updated_at; } + pub fn set_name(&mut self, name: String) { + self.name = name; + self.updated_at = Utc::now(); + } + pub fn set_description(&mut self, description: Option) { + self.description = description; + self.updated_at = Utc::now(); + } + pub fn set_color(&mut self, color: Option) { + self.color = color; + self.updated_at = Utc::now(); + } + pub fn set_is_public(&mut self, is_public: bool) { + self.is_public = is_public; + self.updated_at = Utc::now(); + } + pub fn set_updated_at(&mut self, updated_at: DateTime) { + self.updated_at = updated_at; + } } impl Default for AddressBook { @@ -198,72 +237,191 @@ impl Contact { updated_at: DateTime, ) -> Self { Self { - id, address_book_id, uid, full_name, first_name, last_name, nickname, - email, phone, address, organization, title, notes, photo_url, - birthday, anniversary, vcard, etag, created_at, updated_at, + id, + address_book_id, + uid, + full_name, + first_name, + last_name, + nickname, + email, + phone, + address, + organization, + title, + notes, + photo_url, + birthday, + anniversary, + vcard, + etag, + created_at, + updated_at, } } // --- Getters --- - pub fn id(&self) -> &Uuid { &self.id } - pub fn address_book_id(&self) -> &Uuid { &self.address_book_id } - pub fn uid(&self) -> &str { &self.uid } - pub fn full_name(&self) -> Option<&str> { self.full_name.as_deref() } - pub fn first_name(&self) -> Option<&str> { self.first_name.as_deref() } - pub fn last_name(&self) -> Option<&str> { self.last_name.as_deref() } - pub fn nickname(&self) -> Option<&str> { self.nickname.as_deref() } - pub fn email(&self) -> &[Email] { &self.email } - pub fn phone(&self) -> &[Phone] { &self.phone } - pub fn address(&self) -> &[Address] { &self.address } - pub fn organization(&self) -> Option<&str> { self.organization.as_deref() } - pub fn title(&self) -> Option<&str> { self.title.as_deref() } - pub fn notes(&self) -> Option<&str> { self.notes.as_deref() } - pub fn photo_url(&self) -> Option<&str> { self.photo_url.as_deref() } - pub fn birthday(&self) -> Option<&NaiveDate> { self.birthday.as_ref() } - pub fn anniversary(&self) -> Option<&NaiveDate> { self.anniversary.as_ref() } - pub fn vcard(&self) -> &str { &self.vcard } - pub fn etag(&self) -> &str { &self.etag } - pub fn created_at(&self) -> &DateTime { &self.created_at } - pub fn updated_at(&self) -> &DateTime { &self.updated_at } + pub fn id(&self) -> &Uuid { + &self.id + } + pub fn address_book_id(&self) -> &Uuid { + &self.address_book_id + } + pub fn uid(&self) -> &str { + &self.uid + } + pub fn full_name(&self) -> Option<&str> { + self.full_name.as_deref() + } + pub fn first_name(&self) -> Option<&str> { + self.first_name.as_deref() + } + pub fn last_name(&self) -> Option<&str> { + self.last_name.as_deref() + } + pub fn nickname(&self) -> Option<&str> { + self.nickname.as_deref() + } + pub fn email(&self) -> &[Email] { + &self.email + } + pub fn phone(&self) -> &[Phone] { + &self.phone + } + pub fn address(&self) -> &[Address] { + &self.address + } + pub fn organization(&self) -> Option<&str> { + self.organization.as_deref() + } + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + pub fn notes(&self) -> Option<&str> { + self.notes.as_deref() + } + pub fn photo_url(&self) -> Option<&str> { + self.photo_url.as_deref() + } + pub fn birthday(&self) -> Option<&NaiveDate> { + self.birthday.as_ref() + } + pub fn anniversary(&self) -> Option<&NaiveDate> { + self.anniversary.as_ref() + } + pub fn vcard(&self) -> &str { + &self.vcard + } + pub fn etag(&self) -> &str { + &self.etag + } + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } // --- Owned getters for persistence layer bind() calls --- - pub fn full_name_owned(&self) -> Option { self.full_name.clone() } - pub fn first_name_owned(&self) -> Option { self.first_name.clone() } - pub fn last_name_owned(&self) -> Option { self.last_name.clone() } - pub fn nickname_owned(&self) -> Option { self.nickname.clone() } - pub fn organization_owned(&self) -> Option { self.organization.clone() } - pub fn title_owned(&self) -> Option { self.title.clone() } - pub fn notes_owned(&self) -> Option { self.notes.clone() } - pub fn photo_url_owned(&self) -> Option { self.photo_url.clone() } + pub fn full_name_owned(&self) -> Option { + self.full_name.clone() + } + pub fn first_name_owned(&self) -> Option { + self.first_name.clone() + } + pub fn last_name_owned(&self) -> Option { + self.last_name.clone() + } + pub fn nickname_owned(&self) -> Option { + self.nickname.clone() + } + pub fn organization_owned(&self) -> Option { + self.organization.clone() + } + pub fn title_owned(&self) -> Option { + self.title.clone() + } + pub fn notes_owned(&self) -> Option { + self.notes.clone() + } + pub fn photo_url_owned(&self) -> Option { + self.photo_url.clone() + } // --- Setters for mutable operations (contact_service.rs needs these) --- - pub fn set_full_name(&mut self, v: Option) { self.full_name = v; } - pub fn set_first_name(&mut self, v: Option) { self.first_name = v; } - pub fn set_last_name(&mut self, v: Option) { self.last_name = v; } - pub fn set_nickname(&mut self, v: Option) { self.nickname = v; } - pub fn set_organization(&mut self, v: Option) { self.organization = v; } - pub fn set_title(&mut self, v: Option) { self.title = v; } - pub fn set_notes(&mut self, v: Option) { self.notes = v; } - pub fn set_photo_url(&mut self, v: Option) { self.photo_url = v; } - pub fn set_birthday(&mut self, v: Option) { self.birthday = v; } - pub fn set_anniversary(&mut self, v: Option) { self.anniversary = v; } - pub fn set_vcard(&mut self, vcard: String) { self.vcard = vcard; } - pub fn set_etag(&mut self, etag: String) { self.etag = etag; } - pub fn set_updated_at(&mut self, updated_at: DateTime) { self.updated_at = updated_at; } - pub fn set_address_book_id(&mut self, id: Uuid) { self.address_book_id = id; } - pub fn set_uid(&mut self, uid: String) { self.uid = uid; } + pub fn set_full_name(&mut self, v: Option) { + self.full_name = v; + } + pub fn set_first_name(&mut self, v: Option) { + self.first_name = v; + } + pub fn set_last_name(&mut self, v: Option) { + self.last_name = v; + } + pub fn set_nickname(&mut self, v: Option) { + self.nickname = v; + } + pub fn set_organization(&mut self, v: Option) { + self.organization = v; + } + pub fn set_title(&mut self, v: Option) { + self.title = v; + } + pub fn set_notes(&mut self, v: Option) { + self.notes = v; + } + pub fn set_photo_url(&mut self, v: Option) { + self.photo_url = v; + } + pub fn set_birthday(&mut self, v: Option) { + self.birthday = v; + } + pub fn set_anniversary(&mut self, v: Option) { + self.anniversary = v; + } + pub fn set_vcard(&mut self, vcard: String) { + self.vcard = vcard; + } + pub fn set_etag(&mut self, etag: String) { + self.etag = etag; + } + pub fn set_updated_at(&mut self, updated_at: DateTime) { + self.updated_at = updated_at; + } + pub fn set_address_book_id(&mut self, id: Uuid) { + self.address_book_id = id; + } + pub fn set_uid(&mut self, uid: String) { + self.uid = uid; + } // --- Collection mutators --- - pub fn push_email(&mut self, e: Email) { self.email.push(e); } - pub fn push_phone(&mut self, p: Phone) { self.phone.push(p); } - pub fn set_email(&mut self, email: Vec) { self.email = email; } - pub fn set_phone(&mut self, phone: Vec) { self.phone = phone; } - pub fn set_address(&mut self, address: Vec
) { self.address = address; } - pub fn email_is_empty(&self) -> bool { self.email.is_empty() } - pub fn phone_is_empty(&self) -> bool { self.phone.is_empty() } + pub fn push_email(&mut self, e: Email) { + self.email.push(e); + } + pub fn push_phone(&mut self, p: Phone) { + self.phone.push(p); + } + pub fn set_email(&mut self, email: Vec) { + self.email = email; + } + pub fn set_phone(&mut self, phone: Vec) { + self.phone = phone; + } + pub fn set_address(&mut self, address: Vec
) { + self.address = address; + } + pub fn email_is_empty(&self) -> bool { + self.email.is_empty() + } + pub fn phone_is_empty(&self) -> bool { + self.phone.is_empty() + } // --- Consuming methods for ownership transfer --- - pub fn into_email(self) -> Vec { self.email } + pub fn into_email(self) -> Vec { + self.email + } pub fn into_parts(self) -> ContactParts { ContactParts { id: self.id, @@ -355,7 +513,13 @@ impl ContactGroup { /// Creates a new ContactGroup with generated id and timestamps pub fn new(address_book_id: Uuid, name: String) -> Self { let now = Utc::now(); - Self { id: Uuid::new_v4(), address_book_id, name, created_at: now, updated_at: now } + Self { + id: Uuid::new_v4(), + address_book_id, + name, + created_at: now, + updated_at: now, + } } /// Reconstructs from persistence @@ -366,23 +530,44 @@ impl ContactGroup { created_at: DateTime, updated_at: DateTime, ) -> Self { - Self { id, address_book_id, name, created_at, updated_at } + Self { + id, + address_book_id, + name, + created_at, + updated_at, + } } // --- Getters --- - pub fn id(&self) -> &Uuid { &self.id } - pub fn address_book_id(&self) -> &Uuid { &self.address_book_id } - pub fn name(&self) -> &str { &self.name } - pub fn created_at(&self) -> &DateTime { &self.created_at } - pub fn updated_at(&self) -> &DateTime { &self.updated_at } + pub fn id(&self) -> &Uuid { + &self.id + } + pub fn address_book_id(&self) -> &Uuid { + &self.address_book_id + } + pub fn name(&self) -> &str { + &self.name + } + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } // --- Setters --- - pub fn set_name(&mut self, name: String) { self.name = name; self.updated_at = Utc::now(); } - pub fn set_updated_at(&mut self, updated_at: DateTime) { self.updated_at = updated_at; } + pub fn set_name(&mut self, name: String) { + self.name = name; + self.updated_at = Utc::now(); + } + pub fn set_updated_at(&mut self, updated_at: DateTime) { + self.updated_at = updated_at; + } } impl Default for ContactGroup { fn default() -> Self { ContactGroup::new(Uuid::new_v4(), "New Group".to_string()) } -} \ No newline at end of file +} diff --git a/src/domain/entities/entity_errors.rs b/src/domain/entities/entity_errors.rs index a4d72620..f166a7fa 100644 --- a/src/domain/entities/entity_errors.rs +++ b/src/domain/entities/entity_errors.rs @@ -1,254 +1,258 @@ -//! Pure domain entity errors -//! -//! This module defines domain entity-specific errors -//! without external framework dependencies, following -//! Clean Architecture principles. -//! -//! Errors manually implement `std::error::Error` and `std::fmt::Display` -//! to keep the domain free of external dependencies. - -use std::error::Error; -use std::fmt::{Display, Formatter, Result as FmtResult}; - -// ============================================================================ -// FILE ERRORS -// ============================================================================ - -/// Errors that can occur during File entity operations -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FileError { - /// Occurs when the file name contains invalid characters or is empty - InvalidFileName(String), - /// Occurs when validation of any entity attribute fails - ValidationError(String), -} - -impl Display for FileError { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - FileError::InvalidFileName(name) => write!(f, "Invalid file name: {}", name), - FileError::ValidationError(msg) => write!(f, "Validation error: {}", msg), - } - } -} - -impl Error for FileError {} - -/// Type alias for File entity operation results -pub type FileResult = Result; - -// ============================================================================ -// FOLDER ERRORS -// ============================================================================ - -/// Errors that can occur during Folder entity operations -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FolderError { - /// Occurs when the folder name contains invalid characters or is empty - InvalidFolderName(String), - /// Occurs when validation of any entity attribute fails - ValidationError(String), -} - -impl Display for FolderError { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - FolderError::InvalidFolderName(name) => write!(f, "Invalid folder name: {}", name), - FolderError::ValidationError(msg) => write!(f, "Validation error: {}", msg), - } - } -} - -impl Error for FolderError {} - -/// Type alias for Folder entity operation results -pub type FolderResult = Result; - -// ============================================================================ -// USER ERRORS -// ============================================================================ - -/// Errors that can occur during User entity operations -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UserError { - /// Invalid username - InvalidUsername(String), - /// Invalid password - InvalidPassword(String), - /// General validation error - ValidationError(String), - /// Authentication error - AuthenticationError(String), -} - -impl Display for UserError { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - UserError::InvalidUsername(msg) => write!(f, "Invalid username: {}", msg), - UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg), - UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg), - UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg), - } - } -} - -impl Error for UserError {} - -/// Type alias for User entity operation results -pub type UserResult = Result; - -// ============================================================================ -// SHARE ERRORS -// ============================================================================ - -/// Errors that can occur during Share entity operations -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ShareError { - /// Invalid share token - InvalidToken(String), - /// Invalid expiration date - InvalidExpiration(String), - /// General validation error - ValidationError(String), -} - -impl Display for ShareError { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - ShareError::InvalidToken(msg) => write!(f, "Invalid token: {}", msg), - ShareError::InvalidExpiration(msg) => write!(f, "Invalid expiration date: {}", msg), - ShareError::ValidationError(msg) => write!(f, "Validation error: {}", msg), - } - } -} - -impl Error for ShareError {} - -/// Type alias for Share entity operation results -pub type ShareResult = Result; - -// ============================================================================ -// CALENDAR ERRORS -// ============================================================================ - -/// Errors that can occur during Calendar entity operations -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CalendarError { - /// Invalid calendar name - InvalidName(String), - /// Invalid color code - InvalidColor(String), - /// Invalid owner ID - InvalidOwnerId(String), -} - -impl Display for CalendarError { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - CalendarError::InvalidName(msg) => write!(f, "Invalid calendar name: {}", msg), - CalendarError::InvalidColor(msg) => write!(f, "Invalid color code: {}", msg), - CalendarError::InvalidOwnerId(msg) => write!(f, "Invalid owner ID: {}", msg), - } - } -} - -impl Error for CalendarError {} - -/// Type alias for Calendar entity operation results -pub type CalendarResult = Result; - -// ============================================================================ -// CALENDAR EVENT ERRORS -// ============================================================================ - -/// Errors that can occur during CalendarEvent entity operations -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CalendarEventError { - /// Invalid event summary/title - InvalidSummary(String), - /// Invalid event dates - InvalidDates(String), - /// Invalid recurrence rule - InvalidRecurrence(String), - /// Invalid iCalendar data - InvalidICalData(String), -} - -impl Display for CalendarEventError { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - CalendarEventError::InvalidSummary(msg) => write!(f, "Invalid event summary: {}", msg), - CalendarEventError::InvalidDates(msg) => write!(f, "Invalid event dates: {}", msg), - CalendarEventError::InvalidRecurrence(msg) => write!(f, "Invalid recurrence rule: {}", msg), - CalendarEventError::InvalidICalData(msg) => write!(f, "Invalid iCalendar data: {}", msg), - } - } -} - -impl Error for CalendarEventError {} - -/// Type alias for CalendarEvent entity operation results -pub type CalendarEventResult = Result; - -// ============================================================================ -// TESTS -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_file_error_display() { - let err = FileError::InvalidFileName("test.txt".to_string()); - assert_eq!(err.to_string(), "Invalid file name: test.txt"); - - let err = FileError::ValidationError("size too large".to_string()); - assert_eq!(err.to_string(), "Validation error: size too large"); - } - - #[test] - fn test_folder_error_display() { - let err = FolderError::InvalidFolderName("my/folder".to_string()); - assert_eq!(err.to_string(), "Invalid folder name: my/folder"); - } - - #[test] - fn test_user_error_display() { - let err = UserError::InvalidUsername("".to_string()); - assert_eq!(err.to_string(), "Invalid username: "); - - let err = UserError::AuthenticationError("invalid credentials".to_string()); - assert_eq!(err.to_string(), "Authentication error: invalid credentials"); - } - - #[test] - fn test_share_error_display() { - let err = ShareError::InvalidToken("abc123".to_string()); - assert_eq!(err.to_string(), "Invalid token: abc123"); - } - - #[test] - fn test_calendar_error_display() { - let err = CalendarError::InvalidColor("not-a-color".to_string()); - assert_eq!(err.to_string(), "Invalid color code: not-a-color"); - } - - #[test] - fn test_calendar_event_error_display() { - let err = CalendarEventError::InvalidDates("end before start".to_string()); - assert_eq!(err.to_string(), "Invalid event dates: end before start"); - } - - #[test] - fn test_errors_implement_error_trait() { - fn assert_error() {} - - assert_error::(); - assert_error::(); - assert_error::(); - assert_error::(); - assert_error::(); - assert_error::(); - } -} +//! Pure domain entity errors +//! +//! This module defines domain entity-specific errors +//! without external framework dependencies, following +//! Clean Architecture principles. +//! +//! Errors manually implement `std::error::Error` and `std::fmt::Display` +//! to keep the domain free of external dependencies. + +use std::error::Error; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +// ============================================================================ +// FILE ERRORS +// ============================================================================ + +/// Errors that can occur during File entity operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileError { + /// Occurs when the file name contains invalid characters or is empty + InvalidFileName(String), + /// Occurs when validation of any entity attribute fails + ValidationError(String), +} + +impl Display for FileError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + FileError::InvalidFileName(name) => write!(f, "Invalid file name: {}", name), + FileError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl Error for FileError {} + +/// Type alias for File entity operation results +pub type FileResult = Result; + +// ============================================================================ +// FOLDER ERRORS +// ============================================================================ + +/// Errors that can occur during Folder entity operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FolderError { + /// Occurs when the folder name contains invalid characters or is empty + InvalidFolderName(String), + /// Occurs when validation of any entity attribute fails + ValidationError(String), +} + +impl Display for FolderError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + FolderError::InvalidFolderName(name) => write!(f, "Invalid folder name: {}", name), + FolderError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl Error for FolderError {} + +/// Type alias for Folder entity operation results +pub type FolderResult = Result; + +// ============================================================================ +// USER ERRORS +// ============================================================================ + +/// Errors that can occur during User entity operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserError { + /// Invalid username + InvalidUsername(String), + /// Invalid password + InvalidPassword(String), + /// General validation error + ValidationError(String), + /// Authentication error + AuthenticationError(String), +} + +impl Display for UserError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + UserError::InvalidUsername(msg) => write!(f, "Invalid username: {}", msg), + UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg), + UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg), + } + } +} + +impl Error for UserError {} + +/// Type alias for User entity operation results +pub type UserResult = Result; + +// ============================================================================ +// SHARE ERRORS +// ============================================================================ + +/// Errors that can occur during Share entity operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShareError { + /// Invalid share token + InvalidToken(String), + /// Invalid expiration date + InvalidExpiration(String), + /// General validation error + ValidationError(String), +} + +impl Display for ShareError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + ShareError::InvalidToken(msg) => write!(f, "Invalid token: {}", msg), + ShareError::InvalidExpiration(msg) => write!(f, "Invalid expiration date: {}", msg), + ShareError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl Error for ShareError {} + +/// Type alias for Share entity operation results +pub type ShareResult = Result; + +// ============================================================================ +// CALENDAR ERRORS +// ============================================================================ + +/// Errors that can occur during Calendar entity operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CalendarError { + /// Invalid calendar name + InvalidName(String), + /// Invalid color code + InvalidColor(String), + /// Invalid owner ID + InvalidOwnerId(String), +} + +impl Display for CalendarError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + CalendarError::InvalidName(msg) => write!(f, "Invalid calendar name: {}", msg), + CalendarError::InvalidColor(msg) => write!(f, "Invalid color code: {}", msg), + CalendarError::InvalidOwnerId(msg) => write!(f, "Invalid owner ID: {}", msg), + } + } +} + +impl Error for CalendarError {} + +/// Type alias for Calendar entity operation results +pub type CalendarResult = Result; + +// ============================================================================ +// CALENDAR EVENT ERRORS +// ============================================================================ + +/// Errors that can occur during CalendarEvent entity operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CalendarEventError { + /// Invalid event summary/title + InvalidSummary(String), + /// Invalid event dates + InvalidDates(String), + /// Invalid recurrence rule + InvalidRecurrence(String), + /// Invalid iCalendar data + InvalidICalData(String), +} + +impl Display for CalendarEventError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + CalendarEventError::InvalidSummary(msg) => write!(f, "Invalid event summary: {}", msg), + CalendarEventError::InvalidDates(msg) => write!(f, "Invalid event dates: {}", msg), + CalendarEventError::InvalidRecurrence(msg) => { + write!(f, "Invalid recurrence rule: {}", msg) + } + CalendarEventError::InvalidICalData(msg) => { + write!(f, "Invalid iCalendar data: {}", msg) + } + } + } +} + +impl Error for CalendarEventError {} + +/// Type alias for CalendarEvent entity operation results +pub type CalendarEventResult = Result; + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_error_display() { + let err = FileError::InvalidFileName("test.txt".to_string()); + assert_eq!(err.to_string(), "Invalid file name: test.txt"); + + let err = FileError::ValidationError("size too large".to_string()); + assert_eq!(err.to_string(), "Validation error: size too large"); + } + + #[test] + fn test_folder_error_display() { + let err = FolderError::InvalidFolderName("my/folder".to_string()); + assert_eq!(err.to_string(), "Invalid folder name: my/folder"); + } + + #[test] + fn test_user_error_display() { + let err = UserError::InvalidUsername("".to_string()); + assert_eq!(err.to_string(), "Invalid username: "); + + let err = UserError::AuthenticationError("invalid credentials".to_string()); + assert_eq!(err.to_string(), "Authentication error: invalid credentials"); + } + + #[test] + fn test_share_error_display() { + let err = ShareError::InvalidToken("abc123".to_string()); + assert_eq!(err.to_string(), "Invalid token: abc123"); + } + + #[test] + fn test_calendar_error_display() { + let err = CalendarError::InvalidColor("not-a-color".to_string()); + assert_eq!(err.to_string(), "Invalid color code: not-a-color"); + } + + #[test] + fn test_calendar_event_error_display() { + let err = CalendarEventError::InvalidDates("end before start".to_string()); + assert_eq!(err.to_string(), "Invalid event dates: end before start"); + } + + #[test] + fn test_errors_implement_error_trait() { + fn assert_error() {} + + assert_error::(); + assert_error::(); + assert_error::(); + assert_error::(); + assert_error::(); + assert_error::(); + } +} diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 172664bf..bca241a7 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -5,11 +5,11 @@ pub use super::entity_errors::{FileError, FileResult}; /** * Represents a file in the system's domain model. - * + * * The File entity is a core domain object that encapsulates all properties and behaviors * of a file in the system. It implements an immutable design pattern where modification * operations return new instances rather than modifying the existing one. - * + * * This entity maintains both physical storage information and logical metadata about files, * serving as the bridge between the storage system and the application. */ @@ -17,28 +17,28 @@ pub use super::entity_errors::{FileError, FileResult}; pub struct File { /// Unique identifier for the file - used throughout the system for file operations id: String, - + /// Name of the file including extension name: String, - + /// Path to the file in the domain model storage_path: StoragePath, - + /// String representation of the path for API compatibility path_string: String, - + /// Size of the file in bytes size: u64, - + /// MIME type of the file (e.g., "text/plain", "image/jpeg") mime_type: String, - + /// Parent folder ID if the file is within a folder, None if in root folder_id: Option, - + /// Creation timestamp (seconds since UNIX epoch) created_at: u64, - + /// Last modification timestamp (seconds since UNIX epoch) modified_at: u64, } @@ -75,15 +75,15 @@ impl File { if name.is_empty() || name.contains('/') || name.contains('\\') { return Err(FileError::InvalidFileName(name)); } - + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + // Store the path string for serialization compatibility let path_string = storage_path.to_string(); - + Ok(Self { id, name, @@ -96,7 +96,7 @@ impl File { modified_at: now, }) } - + /// Creates a folder entity pub fn new_folder( id: String, @@ -110,23 +110,23 @@ impl File { if name.is_empty() || name.contains('/') || name.contains('\\') { return Err(FileError::InvalidFileName(name)); } - + // Store the path string for serialization compatibility let path_string = storage_path.to_string(); - + Ok(Self { id, name, storage_path, path_string, - size: 0, // Folders have zero size + size: 0, // Folders have zero size mime_type: "directory".to_string(), // Standard MIME type for directories folder_id: parent_id, created_at, modified_at, }) } - + /// Creates a file with specific timestamps (for reconstruction) pub fn with_timestamps( id: String, @@ -142,10 +142,10 @@ impl File { if name.is_empty() || name.contains('/') || name.contains('\\') { return Err(FileError::InvalidFileName(name)); } - + // Store the path string for serialization compatibility let path_string = storage_path.to_string(); - + Ok(Self { id, name, @@ -158,44 +158,44 @@ impl File { modified_at, }) } - + // Getters pub fn id(&self) -> &str { &self.id } - + pub fn name(&self) -> &str { &self.name } - + pub fn storage_path(&self) -> &StoragePath { &self.storage_path } - + pub fn path_string(&self) -> &str { &self.path_string } - + pub fn size(&self) -> u64 { self.size } - + pub fn mime_type(&self) -> &str { &self.mime_type } - + pub fn folder_id(&self) -> Option<&str> { self.folder_id.as_deref() } - + pub fn created_at(&self) -> u64 { self.created_at } - + pub fn modified_at(&self) -> u64 { self.modified_at } - + /// Creates a new File instance from a DTO /// This function is primarily for conversions in batch handlers pub fn from_dto( @@ -210,7 +210,7 @@ impl File { ) -> Self { // Create storage_path from string let storage_path = StoragePath::from_string(&path); - + // Create directly without validation to avoid errors in DTO conversions Self { id, @@ -224,31 +224,31 @@ impl File { modified_at, } } - + // Methods to create new versions of the file (immutable) - + /// Creates a new version of the file with updated name pub fn with_name(&self, new_name: String) -> FileResult { // Validate file name if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') { return Err(FileError::InvalidFileName(new_name)); } - + // Update path based on name let parent_path = self.storage_path.parent(); let new_storage_path = match parent_path { Some(parent) => parent.join(&new_name), None => StoragePath::from_string(&new_name), }; - + // Update string representation let new_path_string = new_storage_path.to_string(); - + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + Ok(Self { id: self.id.clone(), name: new_name, @@ -261,23 +261,27 @@ impl File { modified_at: now, }) } - + /// Creates a new version of the file with updated folder - pub fn with_folder(&self, folder_id: Option, folder_path: Option) -> FileResult { + pub fn with_folder( + &self, + folder_id: Option, + folder_path: Option, + ) -> FileResult { // We need a folder path to update the file path let new_storage_path = match folder_path { Some(path) => path.join(&self.name), None => StoragePath::from_string(&self.name), // Root }; - + // Update string representation let new_path_string = new_storage_path.to_string(); - + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + Ok(Self { id: self.id.clone(), name: self.name.clone(), @@ -290,14 +294,14 @@ impl File { modified_at: now, }) } - + /// Creates a new version of the file with updated size pub fn with_size(&self, new_size: u64) -> Self { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + Self { id: self.id.clone(), name: self.name.clone(), @@ -315,7 +319,7 @@ impl File { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_file_creation_with_valid_name() { let storage_path = StoragePath::from_string("/test/file.txt"); @@ -327,10 +331,10 @@ mod tests { "text/plain".to_string(), None, ); - + assert!(file.is_ok()); } - + #[test] fn test_file_creation_with_invalid_name() { let storage_path = StoragePath::from_string("/test/invalid/file.txt"); @@ -342,14 +346,14 @@ mod tests { "text/plain".to_string(), None, ); - + assert!(file.is_err()); match file { Err(FileError::InvalidFileName(_)) => (), _ => panic!("Expected InvalidFileName error"), } } - + #[test] fn test_file_with_name() { let storage_path = StoragePath::from_string("/test/file.txt"); @@ -360,12 +364,13 @@ mod tests { 100, "text/plain".to_string(), None, - ).unwrap(); - + ) + .unwrap(); + let renamed = file.with_name("newname.txt".to_string()); assert!(renamed.is_ok()); let renamed = renamed.unwrap(); assert_eq!(renamed.name(), "newname.txt"); assert_eq!(renamed.id(), "123"); // The ID does not change } -} \ No newline at end of file +} diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index a7565528..0c9f58fb 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -8,22 +8,22 @@ pub use super::entity_errors::{FolderError, FolderResult}; pub struct Folder { /// Unique identifier for the folder id: String, - + /// Name of the folder name: String, - + /// Path to the folder in the domain model storage_path: StoragePath, - + /// String representation of the path (for API compatibility) path_string: String, - + /// Parent folder ID (None if it's a root folder) parent_id: Option, - + /// Creation timestamp created_at: u64, - + /// Last modification timestamp modified_at: u64, } @@ -56,15 +56,15 @@ impl Folder { if name.is_empty() || name.contains('/') || name.contains('\\') { return Err(FolderError::InvalidFolderName(name)); } - + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + // Store the path string for serialization compatibility let path_string = storage_path.to_string(); - + Ok(Self { id, name, @@ -75,7 +75,7 @@ impl Folder { modified_at: now, }) } - + /// Creates a folder with specific timestamps (for reconstruction) pub fn with_timestamps( id: String, @@ -89,10 +89,10 @@ impl Folder { if name.is_empty() || name.contains('/') || name.contains('\\') { return Err(FolderError::InvalidFolderName(name)); } - + // Store the path string for serialization compatibility let path_string = storage_path.to_string(); - + Ok(Self { id, name, @@ -103,36 +103,36 @@ impl Folder { modified_at, }) } - + // Getters pub fn id(&self) -> &str { &self.id } - + pub fn name(&self) -> &str { &self.name } - + pub fn storage_path(&self) -> &StoragePath { &self.storage_path } - + pub fn path_string(&self) -> &str { &self.path_string } - + pub fn parent_id(&self) -> Option<&str> { self.parent_id.as_deref() } - + pub fn created_at(&self) -> u64 { self.created_at } - + pub fn modified_at(&self) -> u64 { self.modified_at } - + /// Creates a new Folder instance from a DTO /// This function is primarily for conversions in batch handlers pub fn from_dto( @@ -145,7 +145,7 @@ impl Folder { ) -> Self { // Create storage_path from the string let storage_path = StoragePath::from_string(&path); - + // Create directly without validation to avoid errors in DTO conversions Self { id, @@ -157,31 +157,31 @@ impl Folder { modified_at, } } - + // Methods to create new versions of the folder (immutable) - + /// Creates a new version of the folder with updated name pub fn with_name(&self, new_name: String) -> FolderResult { // Validate folder name if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') { return Err(FolderError::InvalidFolderName(new_name)); } - + // Update path based on the name let parent_path = self.storage_path.parent(); let new_storage_path = match parent_path { Some(parent) => parent.join(&new_name), None => StoragePath::from_string(&new_name), }; - + // Update string representation let new_path_string = new_storage_path.to_string(); - + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + Ok(Self { id: self.id.clone(), name: new_name, @@ -192,23 +192,27 @@ impl Folder { modified_at: now, }) } - + /// Creates a new version of the folder with updated parent - pub fn with_parent(&self, parent_id: Option, parent_path: Option) -> FolderResult { + pub fn with_parent( + &self, + parent_id: Option, + parent_path: Option, + ) -> FolderResult { // We need a folder path to update the path let new_storage_path = match parent_path { Some(path) => path.join(&self.name), None => StoragePath::from_string(&self.name), // Root }; - + // Update string representation let new_path_string = new_storage_path.to_string(); - + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + Ok(Self { id: self.id.clone(), name: self.name.clone(), @@ -219,22 +223,22 @@ impl Folder { modified_at: now, }) } - + /// Returns an absolute path for this folder pub fn get_absolute_path>(&self, root_path: P) -> std::path::PathBuf { let mut result = std::path::PathBuf::from(root_path.as_ref()); - + // Skip leading '/' from path_string to avoid creating absolute path incorrectly let relative_path = if self.path_string.starts_with('/') { &self.path_string[1..] } else { &self.path_string }; - + if !relative_path.is_empty() { result.push(relative_path); } - + result } } @@ -242,7 +246,7 @@ impl Folder { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_folder_creation_with_valid_name() { let storage_path = StoragePath::from_string("/test/folder"); @@ -252,10 +256,10 @@ mod tests { storage_path, None, ); - + assert!(folder.is_ok()); } - + #[test] fn test_folder_creation_with_invalid_name() { let storage_path = StoragePath::from_string("/test/invalid/folder"); @@ -265,14 +269,14 @@ mod tests { storage_path, None, ); - + assert!(folder.is_err()); match folder { Err(FolderError::InvalidFolderName(_)) => (), _ => panic!("Expected InvalidFolderName error"), } } - + #[test] fn test_folder_with_name() { let storage_path = StoragePath::from_string("/test/folder"); @@ -281,12 +285,13 @@ mod tests { "old_name".to_string(), storage_path, None, - ).unwrap(); - + ) + .unwrap(); + let renamed = folder.with_name("new_name".to_string()); assert!(renamed.is_ok()); let renamed = renamed.unwrap(); assert_eq!(renamed.name(), "new_name"); assert_eq!(renamed.id(), "123"); // The ID doesn't change } -} \ No newline at end of file +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index f6dd0c46..2f3ba64a 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -4,17 +4,13 @@ pub mod contact; pub mod entity_errors; pub mod file; pub mod folder; -pub mod user; pub mod session; pub mod share; pub mod trashed_item; +pub mod user; // Re-exportar errores de entidades para facilitar el uso pub use entity_errors::{ - FileError, FileResult, - FolderError, FolderResult, - UserError, UserResult, - ShareError, ShareResult, - CalendarError, CalendarResult, - CalendarEventError, CalendarEventResult, -}; \ No newline at end of file + CalendarError, CalendarEventError, CalendarEventResult, CalendarResult, FileError, FileResult, + FolderError, FolderResult, ShareError, ShareResult, UserError, UserResult, +}; diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index 98368d95..1a7b3352 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -1,5 +1,5 @@ +use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; -use chrono::{DateTime, Utc, Duration}; #[derive(Debug, Clone)] pub struct Session { @@ -64,20 +64,20 @@ impl Session { revoked, } } - + // Getters pub fn id(&self) -> &str { &self.id } - + pub fn user_id(&self) -> &str { &self.user_id } - + pub fn refresh_token(&self) -> &str { &self.refresh_token } - + pub fn expires_at(&self) -> DateTime { self.expires_at } @@ -89,20 +89,20 @@ impl Session { pub fn user_agent(&self) -> Option<&str> { self.user_agent.as_deref() } - + pub fn created_at(&self) -> DateTime { self.created_at } - + pub fn is_expired(&self) -> bool { Utc::now() > self.expires_at } - + pub fn is_revoked(&self) -> bool { self.revoked } - + pub fn revoke(&mut self) { self.revoked = true; } -} \ No newline at end of file +} diff --git a/src/domain/entities/share.rs b/src/domain/entities/share.rs index d009c4d3..c81ad9dd 100644 --- a/src/domain/entities/share.rs +++ b/src/domain/entities/share.rs @@ -33,7 +33,7 @@ pub enum ShareItemType { impl Share { pub fn new( - item_id: String, + item_id: String, item_type: ShareItemType, created_by: String, permissions: Option, @@ -42,7 +42,9 @@ impl Share { ) -> Result { // Validate item_id if item_id.is_empty() { - return Err(ShareError::ValidationError("Item ID cannot be empty".to_string())); + return Err(ShareError::ValidationError( + "Item ID cannot be empty".to_string(), + )); } // Validate expiration date if provided @@ -51,9 +53,11 @@ impl Share { .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); - + if expires <= now { - return Err(ShareError::InvalidExpiration("Expiration date must be in the future".to_string())); + return Err(ShareError::InvalidExpiration( + "Expiration date must be in the future".to_string(), + )); } } @@ -174,10 +178,10 @@ impl Share { .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); - + return expires_at <= now; } - + false } @@ -192,7 +196,7 @@ impl Share { } /// Returns a reference to the password hash, if one is set. - /// + /// /// Password verification should be performed externally via PasswordHasherPort /// to keep cryptographic dependencies out of the domain layer. pub fn password_hash(&self) -> Option<&str> { @@ -238,7 +242,10 @@ impl TryFrom<&str> for ShareItemType { match s.to_lowercase().as_str() { "file" => Ok(ShareItemType::File), "folder" => Ok(ShareItemType::Folder), - _ => Err(ShareError::ValidationError(format!("Invalid item type: {}", s))), + _ => Err(ShareError::ValidationError(format!( + "Invalid item type: {}", + s + ))), } } } @@ -276,7 +283,7 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); - + // Create a share that expires in the future let future = now + 3600; // 1 hour in the future let share = Share::new( @@ -288,9 +295,9 @@ mod tests { Some(future), ) .unwrap(); - + assert!(!share.is_expired()); - + // Test with past expiration (should fail during creation) let past = now - 3600; // 1 hour in the past let share_result = Share::new( @@ -301,21 +308,30 @@ mod tests { None, Some(past), ); - + assert!(share_result.is_err()); } - + #[test] fn test_share_item_type_conversion() { assert_eq!(ShareItemType::File.to_string(), "file"); assert_eq!(ShareItemType::Folder.to_string(), "folder"); - - assert_eq!(ShareItemType::try_from("file").unwrap(), ShareItemType::File); - assert_eq!(ShareItemType::try_from("folder").unwrap(), ShareItemType::Folder); - assert_eq!(ShareItemType::try_from("FILE").unwrap(), ShareItemType::File); + + assert_eq!( + ShareItemType::try_from("file").unwrap(), + ShareItemType::File + ); + assert_eq!( + ShareItemType::try_from("folder").unwrap(), + ShareItemType::Folder + ); + assert_eq!( + ShareItemType::try_from("FILE").unwrap(), + ShareItemType::File + ); assert!(ShareItemType::try_from("invalid").is_err()); } - + #[test] fn test_has_password_with_hash() { let share = Share::new( @@ -327,11 +343,11 @@ mod tests { None, ) .unwrap(); - + assert!(share.has_password()); assert_eq!(share.password_hash(), Some("some_hash_value")); } - + #[test] fn test_has_password_without_hash() { let share = Share::new( @@ -343,7 +359,7 @@ mod tests { None, ) .unwrap(); - + assert!(!share.has_password()); assert_eq!(share.password_hash(), None); } diff --git a/src/domain/entities/trashed_item.rs b/src/domain/entities/trashed_item.rs index d7280498..c25a7139 100644 --- a/src/domain/entities/trashed_item.rs +++ b/src/domain/entities/trashed_item.rs @@ -103,4 +103,4 @@ impl TrashedItem { let now = Utc::now(); (self.deletion_date - now).num_days().max(0) } -} \ No newline at end of file +} diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index 9927d590..318918d0 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -1,5 +1,5 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; +use uuid::Uuid; // Re-export entity errors from the centralized module pub use super::entity_errors::{UserError, UserResult}; @@ -23,7 +23,7 @@ impl std::fmt::Display for UserRole { #[derive(Debug, Clone)] pub struct User { id: String, - username: String, + username: String, email: String, password_hash: String, role: UserRole, @@ -39,11 +39,11 @@ pub struct User { impl User { /// Create a new user with a pre-hashed password. - /// + /// /// The password hashing should be done externally using PasswordHasherPort /// to maintain clean architecture and keep cryptographic dependencies /// out of the domain layer. - /// + /// /// # Arguments /// * `username` - User's username (3-32 characters) /// * `email` - User's email address @@ -52,26 +52,30 @@ impl User { /// * `storage_quota_bytes` - Storage quota in bytes pub fn new( username: String, - email: String, + email: String, password_hash: String, role: UserRole, storage_quota_bytes: i64, ) -> UserResult { // Validations if username.is_empty() || username.len() < 3 || username.len() > 32 { - return Err(UserError::InvalidUsername("Username must be between 3 and 32 characters".to_string())); + return Err(UserError::InvalidUsername( + "Username must be between 3 and 32 characters".to_string(), + )); } - + if !email.contains('@') || email.len() < 5 { return Err(UserError::ValidationError("Invalid email".to_string())); } - + if password_hash.is_empty() { - return Err(UserError::InvalidPassword("Password hash cannot be empty".to_string())); + return Err(UserError::InvalidPassword( + "Password hash cannot be empty".to_string(), + )); } - + let now = Utc::now(); - + Ok(Self { id: Uuid::new_v4().to_string(), username, @@ -88,7 +92,7 @@ impl User { oidc_subject: None, }) } - + /// Create a new OIDC-authenticated user (no password required). pub fn new_oidc( username: String, @@ -104,9 +108,7 @@ impl User { )); } if !email.contains('@') || email.len() < 5 { - return Err(UserError::ValidationError( - "Invalid email".to_string(), - )); + return Err(UserError::ValidationError("Invalid email".to_string())); } let now = Utc::now(); Ok(Self { @@ -125,7 +127,7 @@ impl User { oidc_subject: Some(oidc_subject), }) } - + // Create from existing values (for reconstruction from DB) pub fn from_data( id: String, @@ -189,48 +191,48 @@ impl User { oidc_subject, } } - + // Getters pub fn id(&self) -> &str { &self.id } - + pub fn username(&self) -> &str { &self.username } - + pub fn email(&self) -> &str { &self.email } - + pub fn role(&self) -> UserRole { self.role } - + pub fn storage_quota_bytes(&self) -> i64 { self.storage_quota_bytes } - + pub fn storage_used_bytes(&self) -> i64 { self.storage_used_bytes } - + pub fn created_at(&self) -> DateTime { self.created_at } - + pub fn updated_at(&self) -> DateTime { self.updated_at } - + pub fn last_login_at(&self) -> Option> { self.last_login_at } - + pub fn is_active(&self) -> bool { self.active } - + pub fn password_hash(&self) -> &str { &self.password_hash } @@ -247,38 +249,38 @@ impl User { pub fn is_oidc_user(&self) -> bool { self.oidc_provider.is_some() } - + /// Update the password hash. - /// + /// /// The new password should be hashed externally using PasswordHasherPort /// before calling this method. pub fn update_password_hash(&mut self, new_hash: String) { self.password_hash = new_hash; self.updated_at = Utc::now(); } - + // Update storage usage pub fn update_storage_used(&mut self, storage_used_bytes: i64) { self.storage_used_bytes = storage_used_bytes; self.updated_at = Utc::now(); } - + // Register login pub fn register_login(&mut self) { let now = Utc::now(); self.last_login_at = Some(now); self.updated_at = now; } - + // Deactivate user pub fn deactivate(&mut self) { self.active = false; self.updated_at = Utc::now(); } - + // Activate user pub fn activate(&mut self) { self.active = true; self.updated_at = Utc::now(); } -} \ No newline at end of file +} diff --git a/src/domain/errors.rs b/src/domain/errors.rs index 186cdbc5..2eabddfd 100644 --- a/src/domain/errors.rs +++ b/src/domain/errors.rs @@ -1,271 +1,267 @@ -//! Domain errors -//! -//! This module contains domain-specific error types. -//! DomainError is the base error used throughout the domain layer. - -use std::fmt::{Display, Formatter, Result as FmtResult}; -use std::error::Error as StdError; -use thiserror::Error; - -/// Common Result type for the domain with DomainError as the standard error -pub type Result = std::result::Result; - -/// Domain error types -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ErrorKind { - /// Entity not found - NotFound, - /// Entity already exists - AlreadyExists, - /// Invalid input or failed validation - InvalidInput, - /// Access or permissions error - AccessDenied, - /// Timeout expired - Timeout, - /// Internal system error - InternalError, - /// Functionality not implemented - NotImplemented, - /// Unsupported operation - UnsupportedOperation, - /// Database error - DatabaseError, -} - -impl Display for ErrorKind { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - ErrorKind::NotFound => write!(f, "Not Found"), - ErrorKind::AlreadyExists => write!(f, "Already Exists"), - ErrorKind::InvalidInput => write!(f, "Invalid Input"), - ErrorKind::AccessDenied => write!(f, "Access Denied"), - ErrorKind::Timeout => write!(f, "Timeout"), - ErrorKind::InternalError => write!(f, "Internal Error"), - ErrorKind::NotImplemented => write!(f, "Not Implemented"), - ErrorKind::UnsupportedOperation => write!(f, "Unsupported Operation"), - ErrorKind::DatabaseError => write!(f, "Database Error"), - } - } -} - -/// Base domain error that provides detailed context -#[derive(Error, Debug)] -#[error("{kind}: {message}")] -pub struct DomainError { - /// Error type - pub kind: ErrorKind, - /// Affected entity type (e.g.: "File", "Folder") - pub entity_type: &'static str, - /// Entity identifier if available - pub entity_id: Option, - /// Descriptive error message - pub message: String, - /// Source error (optional) - #[source] - pub source: Option>, -} - -impl DomainError { - /// Creates a new domain error - pub fn new>( - kind: ErrorKind, - entity_type: &'static str, - message: S, - ) -> Self { - Self { - kind, - entity_type, - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Creates an entity not found error - pub fn not_found>(entity_type: &'static str, entity_id: S) -> Self { - let id = entity_id.into(); - Self { - kind: ErrorKind::NotFound, - entity_type, - entity_id: Some(id.clone()), - message: format!("{} not found: {}", entity_type, id), - source: None, - } - } - - /// Creates an entity already exists error - pub fn already_exists>(entity_type: &'static str, entity_id: S) -> Self { - let id = entity_id.into(); - Self { - kind: ErrorKind::AlreadyExists, - entity_type, - entity_id: Some(id.clone()), - message: format!("{} already exists: {}", entity_type, id), - source: None, - } - } - - /// Creates an error for unsupported operations - pub fn operation_not_supported>(entity_type: &'static str, message: S) -> Self { - Self::new( - ErrorKind::UnsupportedOperation, - entity_type, - message, - ) - } - - /// Creates a timeout error - pub fn timeout>(entity_type: &'static str, message: S) -> Self { - Self { - kind: ErrorKind::Timeout, - entity_type, - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Creates an internal error - pub fn internal_error>(entity_type: &'static str, message: S) -> Self { - Self { - kind: ErrorKind::InternalError, - entity_type, - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Creates an access denied error - pub fn access_denied>(entity_type: &'static str, message: S) -> Self { - Self { - kind: ErrorKind::AccessDenied, - entity_type, - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Alias for access_denied to maintain compatibility - pub fn unauthorized>(message: S) -> Self { - Self { - kind: ErrorKind::AccessDenied, - entity_type: "Authorization", - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Creates a database error - pub fn database_error>(message: S) -> Self { - Self { - kind: ErrorKind::DatabaseError, - entity_type: "Database", - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Creates a validation error - pub fn validation_error>(message: S) -> Self { - Self { - kind: ErrorKind::InvalidInput, - entity_type: "Validation", - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Creates a not implemented error - pub fn not_implemented>(entity_type: &'static str, message: S) -> Self { - Self { - kind: ErrorKind::NotImplemented, - entity_type, - entity_id: None, - message: message.into(), - source: None, - } - } - - /// Sets the entity ID - pub fn with_id>(mut self, entity_id: S) -> Self { - self.entity_id = Some(entity_id.into()); - self - } - - /// Sets the source error - pub fn with_source(mut self, source: E) -> Self { - self.source = Some(Box::new(source)); - self - } -} - -/// Trait for adding context to errors -pub trait ErrorContext { - fn with_context(self, context: F) -> std::result::Result - where - C: Into, - F: FnOnce() -> C; - - fn with_error_kind(self, kind: ErrorKind, entity_type: &'static str) -> std::result::Result; -} - -impl ErrorContext for std::result::Result { - fn with_context(self, context: F) -> std::result::Result - where - C: Into, - F: FnOnce() -> C, - { - self.map_err(|e| { - DomainError { - kind: ErrorKind::InternalError, - entity_type: "Unknown", - entity_id: None, - message: context().into(), - source: Some(Box::new(e)), - } - }) - } - - fn with_error_kind(self, kind: ErrorKind, entity_type: &'static str) -> std::result::Result { - self.map_err(|e| { - DomainError { - kind, - entity_type, - entity_id: None, - message: format!("{}", e), - source: Some(Box::new(e)), - } - }) - } -} - -// From implementations for standard errors (without external infrastructure dependencies) -impl From for DomainError { - fn from(err: std::io::Error) -> Self { - DomainError { - kind: ErrorKind::InternalError, - entity_type: "IO", - entity_id: None, - message: format!("{}", err), - source: Some(Box::new(err)), - } - } -} - -impl From for DomainError { - fn from(err: uuid::Error) -> Self { - DomainError { - kind: ErrorKind::InvalidInput, - entity_type: "UUID", - entity_id: None, - message: format!("{}", err), - source: Some(Box::new(err)), - } - } -} +//! Domain errors +//! +//! This module contains domain-specific error types. +//! DomainError is the base error used throughout the domain layer. + +use std::error::Error as StdError; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use thiserror::Error; + +/// Common Result type for the domain with DomainError as the standard error +pub type Result = std::result::Result; + +/// Domain error types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// Entity not found + NotFound, + /// Entity already exists + AlreadyExists, + /// Invalid input or failed validation + InvalidInput, + /// Access or permissions error + AccessDenied, + /// Timeout expired + Timeout, + /// Internal system error + InternalError, + /// Functionality not implemented + NotImplemented, + /// Unsupported operation + UnsupportedOperation, + /// Database error + DatabaseError, +} + +impl Display for ErrorKind { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + ErrorKind::NotFound => write!(f, "Not Found"), + ErrorKind::AlreadyExists => write!(f, "Already Exists"), + ErrorKind::InvalidInput => write!(f, "Invalid Input"), + ErrorKind::AccessDenied => write!(f, "Access Denied"), + ErrorKind::Timeout => write!(f, "Timeout"), + ErrorKind::InternalError => write!(f, "Internal Error"), + ErrorKind::NotImplemented => write!(f, "Not Implemented"), + ErrorKind::UnsupportedOperation => write!(f, "Unsupported Operation"), + ErrorKind::DatabaseError => write!(f, "Database Error"), + } + } +} + +/// Base domain error that provides detailed context +#[derive(Error, Debug)] +#[error("{kind}: {message}")] +pub struct DomainError { + /// Error type + pub kind: ErrorKind, + /// Affected entity type (e.g.: "File", "Folder") + pub entity_type: &'static str, + /// Entity identifier if available + pub entity_id: Option, + /// Descriptive error message + pub message: String, + /// Source error (optional) + #[source] + pub source: Option>, +} + +impl DomainError { + /// Creates a new domain error + pub fn new>(kind: ErrorKind, entity_type: &'static str, message: S) -> Self { + Self { + kind, + entity_type, + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Creates an entity not found error + pub fn not_found>(entity_type: &'static str, entity_id: S) -> Self { + let id = entity_id.into(); + Self { + kind: ErrorKind::NotFound, + entity_type, + entity_id: Some(id.clone()), + message: format!("{} not found: {}", entity_type, id), + source: None, + } + } + + /// Creates an entity already exists error + pub fn already_exists>(entity_type: &'static str, entity_id: S) -> Self { + let id = entity_id.into(); + Self { + kind: ErrorKind::AlreadyExists, + entity_type, + entity_id: Some(id.clone()), + message: format!("{} already exists: {}", entity_type, id), + source: None, + } + } + + /// Creates an error for unsupported operations + pub fn operation_not_supported>(entity_type: &'static str, message: S) -> Self { + Self::new(ErrorKind::UnsupportedOperation, entity_type, message) + } + + /// Creates a timeout error + pub fn timeout>(entity_type: &'static str, message: S) -> Self { + Self { + kind: ErrorKind::Timeout, + entity_type, + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Creates an internal error + pub fn internal_error>(entity_type: &'static str, message: S) -> Self { + Self { + kind: ErrorKind::InternalError, + entity_type, + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Creates an access denied error + pub fn access_denied>(entity_type: &'static str, message: S) -> Self { + Self { + kind: ErrorKind::AccessDenied, + entity_type, + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Alias for access_denied to maintain compatibility + pub fn unauthorized>(message: S) -> Self { + Self { + kind: ErrorKind::AccessDenied, + entity_type: "Authorization", + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Creates a database error + pub fn database_error>(message: S) -> Self { + Self { + kind: ErrorKind::DatabaseError, + entity_type: "Database", + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Creates a validation error + pub fn validation_error>(message: S) -> Self { + Self { + kind: ErrorKind::InvalidInput, + entity_type: "Validation", + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Creates a not implemented error + pub fn not_implemented>(entity_type: &'static str, message: S) -> Self { + Self { + kind: ErrorKind::NotImplemented, + entity_type, + entity_id: None, + message: message.into(), + source: None, + } + } + + /// Sets the entity ID + pub fn with_id>(mut self, entity_id: S) -> Self { + self.entity_id = Some(entity_id.into()); + self + } + + /// Sets the source error + pub fn with_source(mut self, source: E) -> Self { + self.source = Some(Box::new(source)); + self + } +} + +/// Trait for adding context to errors +pub trait ErrorContext { + fn with_context(self, context: F) -> std::result::Result + where + C: Into, + F: FnOnce() -> C; + + fn with_error_kind( + self, + kind: ErrorKind, + entity_type: &'static str, + ) -> std::result::Result; +} + +impl ErrorContext for std::result::Result { + fn with_context(self, context: F) -> std::result::Result + where + C: Into, + F: FnOnce() -> C, + { + self.map_err(|e| DomainError { + kind: ErrorKind::InternalError, + entity_type: "Unknown", + entity_id: None, + message: context().into(), + source: Some(Box::new(e)), + }) + } + + fn with_error_kind( + self, + kind: ErrorKind, + entity_type: &'static str, + ) -> std::result::Result { + self.map_err(|e| DomainError { + kind, + entity_type, + entity_id: None, + message: format!("{}", e), + source: Some(Box::new(e)), + }) + } +} + +// From implementations for standard errors (without external infrastructure dependencies) +impl From for DomainError { + fn from(err: std::io::Error) -> Self { + DomainError { + kind: ErrorKind::InternalError, + entity_type: "IO", + entity_id: None, + message: format!("{}", err), + source: Some(Box::new(err)), + } + } +} + +impl From for DomainError { + fn from(err: uuid::Error) -> Self { + DomainError { + kind: ErrorKind::InvalidInput, + entity_type: "UUID", + entity_id: None, + message: format!("{}", err), + source: Some(Box::new(err)), + } + } +} diff --git a/src/domain/repositories/address_book_repository.rs b/src/domain/repositories/address_book_repository.rs index 92e0cca3..d6e8391e 100644 --- a/src/domain/repositories/address_book_repository.rs +++ b/src/domain/repositories/address_book_repository.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use uuid::Uuid; use std::result::Result; +use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::entities::contact::AddressBook; @@ -9,14 +9,41 @@ pub type AddressBookRepositoryResult = Result; #[async_trait] pub trait AddressBookRepository: Send + Sync + 'static { - async fn create_address_book(&self, address_book: AddressBook) -> AddressBookRepositoryResult; - async fn update_address_book(&self, address_book: AddressBook) -> AddressBookRepositoryResult; + async fn create_address_book( + &self, + address_book: AddressBook, + ) -> AddressBookRepositoryResult; + async fn update_address_book( + &self, + address_book: AddressBook, + ) -> AddressBookRepositoryResult; async fn delete_address_book(&self, id: &Uuid) -> AddressBookRepositoryResult<()>; - async fn get_address_book_by_id(&self, id: &Uuid) -> AddressBookRepositoryResult>; - async fn get_address_books_by_owner(&self, owner_id: &str) -> AddressBookRepositoryResult>; - async fn get_shared_address_books(&self, user_id: &str) -> AddressBookRepositoryResult>; + async fn get_address_book_by_id( + &self, + id: &Uuid, + ) -> AddressBookRepositoryResult>; + async fn get_address_books_by_owner( + &self, + owner_id: &str, + ) -> AddressBookRepositoryResult>; + async fn get_shared_address_books( + &self, + user_id: &str, + ) -> AddressBookRepositoryResult>; async fn get_public_address_books(&self) -> AddressBookRepositoryResult>; - async fn share_address_book(&self, address_book_id: &Uuid, user_id: &str, can_write: bool) -> AddressBookRepositoryResult<()>; - async fn unshare_address_book(&self, address_book_id: &Uuid, user_id: &str) -> AddressBookRepositoryResult<()>; - async fn get_address_book_shares(&self, address_book_id: &Uuid) -> AddressBookRepositoryResult>; -} \ No newline at end of file + async fn share_address_book( + &self, + address_book_id: &Uuid, + user_id: &str, + can_write: bool, + ) -> AddressBookRepositoryResult<()>; + async fn unshare_address_book( + &self, + address_book_id: &Uuid, + user_id: &str, + ) -> AddressBookRepositoryResult<()>; + async fn get_address_book_shares( + &self, + address_book_id: &Uuid, + ) -> AddressBookRepositoryResult>; +} diff --git a/src/domain/repositories/calendar_event_repository.rs b/src/domain/repositories/calendar_event_repository.rs index 62083f02..d33b57fe 100644 --- a/src/domain/repositories/calendar_event_repository.rs +++ b/src/domain/repositories/calendar_event_repository.rs @@ -1,8 +1,8 @@ -use async_trait::async_trait; -use uuid::Uuid; -use chrono::{DateTime, Utc}; use crate::common::errors::DomainError; use crate::domain::entities::calendar_event::CalendarEvent; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; pub type CalendarEventRepositoryResult = Result; @@ -10,53 +10,76 @@ pub type CalendarEventRepositoryResult = Result; #[async_trait] pub trait CalendarEventRepository: Send + Sync + 'static { /// Creates a new calendar event - async fn create_event(&self, event: CalendarEvent) -> CalendarEventRepositoryResult; - + async fn create_event( + &self, + event: CalendarEvent, + ) -> CalendarEventRepositoryResult; + /// Updates an existing calendar event - async fn update_event(&self, event: CalendarEvent) -> CalendarEventRepositoryResult; - + async fn update_event( + &self, + event: CalendarEvent, + ) -> CalendarEventRepositoryResult; + /// Deletes a calendar event by ID async fn delete_event(&self, id: &Uuid) -> CalendarEventRepositoryResult<()>; - + /// Finds a calendar event by its ID async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult; - + /// Lists all events in a specific calendar - async fn list_events_by_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult>; - + async fn list_events_by_calendar( + &self, + calendar_id: &Uuid, + ) -> CalendarEventRepositoryResult>; + /// Finds events in a calendar by their summary/title (partial match) - async fn find_events_by_summary(&self, calendar_id: &Uuid, summary: &str) -> CalendarEventRepositoryResult>; - + async fn find_events_by_summary( + &self, + calendar_id: &Uuid, + summary: &str, + ) -> CalendarEventRepositoryResult>; + /// Gets events in a specific time range for a calendar async fn get_events_in_time_range( - &self, - calendar_id: &Uuid, - start: &DateTime, - end: &DateTime + &self, + calendar_id: &Uuid, + start: &DateTime, + end: &DateTime, ) -> CalendarEventRepositoryResult>; - + /// Finds an event by its iCalendar UID in a specific calendar - async fn find_event_by_ical_uid(&self, calendar_id: &Uuid, ical_uid: &str) -> CalendarEventRepositoryResult>; - + async fn find_event_by_ical_uid( + &self, + calendar_id: &Uuid, + ical_uid: &str, + ) -> CalendarEventRepositoryResult>; + /// Counts events in a calendar - async fn count_events_in_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult; - + async fn count_events_in_calendar( + &self, + calendar_id: &Uuid, + ) -> CalendarEventRepositoryResult; + /// Deletes all events in a calendar - async fn delete_all_events_in_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult; - + async fn delete_all_events_in_calendar( + &self, + calendar_id: &Uuid, + ) -> CalendarEventRepositoryResult; + /// Lists events by calendar with pagination async fn list_events_by_calendar_paginated( - &self, + &self, calendar_id: &Uuid, limit: i64, - offset: i64 + offset: i64, ) -> CalendarEventRepositoryResult>; - + /// Finds events with recurrence rules that might occur in a time range async fn find_recurring_events_in_range( &self, calendar_id: &Uuid, start: &DateTime, - end: &DateTime + end: &DateTime, ) -> CalendarEventRepositoryResult>; -} \ No newline at end of file +} diff --git a/src/domain/repositories/calendar_repository.rs b/src/domain/repositories/calendar_repository.rs index d70c2084..991d39b7 100644 --- a/src/domain/repositories/calendar_repository.rs +++ b/src/domain/repositories/calendar_repository.rs @@ -1,7 +1,7 @@ -use async_trait::async_trait; -use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::entities::calendar::Calendar; +use async_trait::async_trait; +use uuid::Uuid; pub type CalendarRepositoryResult = Result; @@ -10,49 +10,95 @@ pub type CalendarRepositoryResult = Result; pub trait CalendarRepository: Send + Sync + 'static { /// Creates a new calendar async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult; - + /// Updates an existing calendar async fn update_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult; - + /// Deletes a calendar by ID async fn delete_calendar(&self, id: &Uuid) -> CalendarRepositoryResult<()>; - + /// Finds a calendar by its ID async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult; - + /// Lists all calendars for a specific user - async fn list_calendars_by_owner(&self, owner_id: &str) -> CalendarRepositoryResult>; - + async fn list_calendars_by_owner( + &self, + owner_id: &str, + ) -> CalendarRepositoryResult>; + /// Finds a calendar by name and owner - async fn find_calendar_by_name_and_owner(&self, name: &str, owner_id: &str) -> CalendarRepositoryResult; - + async fn find_calendar_by_name_and_owner( + &self, + name: &str, + owner_id: &str, + ) -> CalendarRepositoryResult; + /// Lists calendars shared with a specific user - async fn list_calendars_shared_with_user(&self, user_id: &str) -> CalendarRepositoryResult>; - + async fn list_calendars_shared_with_user( + &self, + user_id: &str, + ) -> CalendarRepositoryResult>; + /// List public calendars - async fn list_public_calendars(&self, limit: i64, offset: i64) -> CalendarRepositoryResult>; - + async fn list_public_calendars( + &self, + limit: i64, + offset: i64, + ) -> CalendarRepositoryResult>; + /// Checks if a user has access to a calendar - async fn user_has_calendar_access(&self, calendar_id: &Uuid, user_id: &str) -> CalendarRepositoryResult; - + async fn user_has_calendar_access( + &self, + calendar_id: &Uuid, + user_id: &str, + ) -> CalendarRepositoryResult; + /// Gets a custom property for a calendar - async fn get_calendar_property(&self, calendar_id: &Uuid, property_name: &str) -> CalendarRepositoryResult>; - + async fn get_calendar_property( + &self, + calendar_id: &Uuid, + property_name: &str, + ) -> CalendarRepositoryResult>; + /// Sets a custom property for a calendar - async fn set_calendar_property(&self, calendar_id: &Uuid, property_name: &str, property_value: &str) -> CalendarRepositoryResult<()>; - + async fn set_calendar_property( + &self, + calendar_id: &Uuid, + property_name: &str, + property_value: &str, + ) -> CalendarRepositoryResult<()>; + /// Removes a custom property from a calendar - async fn remove_calendar_property(&self, calendar_id: &Uuid, property_name: &str) -> CalendarRepositoryResult<()>; - + async fn remove_calendar_property( + &self, + calendar_id: &Uuid, + property_name: &str, + ) -> CalendarRepositoryResult<()>; + /// Gets all custom properties for a calendar - async fn get_calendar_properties(&self, calendar_id: &Uuid) -> CalendarRepositoryResult>; - + async fn get_calendar_properties( + &self, + calendar_id: &Uuid, + ) -> CalendarRepositoryResult>; + /// Share calendar with another user - async fn share_calendar(&self, calendar_id: &Uuid, user_id: &str, access_level: &str) -> CalendarRepositoryResult<()>; - + async fn share_calendar( + &self, + calendar_id: &Uuid, + user_id: &str, + access_level: &str, + ) -> CalendarRepositoryResult<()>; + /// Remove calendar sharing for a user - async fn remove_calendar_sharing(&self, calendar_id: &Uuid, user_id: &str) -> CalendarRepositoryResult<()>; - + async fn remove_calendar_sharing( + &self, + calendar_id: &Uuid, + user_id: &str, + ) -> CalendarRepositoryResult<()>; + /// Get calendar sharing information (who has access to this calendar) - async fn get_calendar_shares(&self, calendar_id: &Uuid) -> CalendarRepositoryResult>; -} \ No newline at end of file + async fn get_calendar_shares( + &self, + calendar_id: &Uuid, + ) -> CalendarRepositoryResult>; +} diff --git a/src/domain/repositories/contact_repository.rs b/src/domain/repositories/contact_repository.rs index 810dcbd5..229b1e5e 100644 --- a/src/domain/repositories/contact_repository.rs +++ b/src/domain/repositories/contact_repository.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use uuid::Uuid; use std::result::Result; +use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::entities::contact::{Contact, ContactGroup}; @@ -13,11 +13,23 @@ pub trait ContactRepository: Send + Sync + 'static { async fn update_contact(&self, contact: Contact) -> ContactRepositoryResult; async fn delete_contact(&self, id: &Uuid) -> ContactRepositoryResult<()>; async fn get_contact_by_id(&self, id: &Uuid) -> ContactRepositoryResult>; - async fn get_contact_by_uid(&self, address_book_id: &Uuid, uid: &str) -> ContactRepositoryResult>; - async fn get_contacts_by_address_book(&self, address_book_id: &Uuid) -> ContactRepositoryResult>; + async fn get_contact_by_uid( + &self, + address_book_id: &Uuid, + uid: &str, + ) -> ContactRepositoryResult>; + async fn get_contacts_by_address_book( + &self, + address_book_id: &Uuid, + ) -> ContactRepositoryResult>; async fn get_contacts_by_email(&self, email: &str) -> ContactRepositoryResult>; - async fn get_contacts_by_group(&self, group_id: &Uuid) -> ContactRepositoryResult>; - async fn search_contacts(&self, address_book_id: &Uuid, query: &str) -> ContactRepositoryResult>; + async fn get_contacts_by_group(&self, group_id: &Uuid) + -> ContactRepositoryResult>; + async fn search_contacts( + &self, + address_book_id: &Uuid, + query: &str, + ) -> ContactRepositoryResult>; } #[async_trait] @@ -26,9 +38,24 @@ pub trait ContactGroupRepository: Send + Sync + 'static { async fn update_group(&self, group: ContactGroup) -> ContactRepositoryResult; async fn delete_group(&self, id: &Uuid) -> ContactRepositoryResult<()>; async fn get_group_by_id(&self, id: &Uuid) -> ContactRepositoryResult>; - async fn get_groups_by_address_book(&self, address_book_id: &Uuid) -> ContactRepositoryResult>; - async fn add_contact_to_group(&self, group_id: &Uuid, contact_id: &Uuid) -> ContactRepositoryResult<()>; - async fn remove_contact_from_group(&self, group_id: &Uuid, contact_id: &Uuid) -> ContactRepositoryResult<()>; - async fn get_contacts_in_group(&self, group_id: &Uuid) -> ContactRepositoryResult>; - async fn get_groups_for_contact(&self, contact_id: &Uuid) -> ContactRepositoryResult>; -} \ No newline at end of file + async fn get_groups_by_address_book( + &self, + address_book_id: &Uuid, + ) -> ContactRepositoryResult>; + async fn add_contact_to_group( + &self, + group_id: &Uuid, + contact_id: &Uuid, + ) -> ContactRepositoryResult<()>; + async fn remove_contact_from_group( + &self, + group_id: &Uuid, + contact_id: &Uuid, + ) -> ContactRepositoryResult<()>; + async fn get_contacts_in_group(&self, group_id: &Uuid) + -> ContactRepositoryResult>; + async fn get_groups_for_contact( + &self, + contact_id: &Uuid, + ) -> ContactRepositoryResult>; +} diff --git a/src/domain/repositories/file_repository.rs b/src/domain/repositories/file_repository.rs index 81a00053..b5745a34 100644 --- a/src/domain/repositories/file_repository.rs +++ b/src/domain/repositories/file_repository.rs @@ -15,9 +15,9 @@ use async_trait::async_trait; use bytes::Bytes; use futures::Stream; +use crate::common::errors::DomainError; use crate::domain::entities::file::File; use crate::domain::services::path_service::StoragePath; -use crate::common::errors::DomainError; // ───────────────────────────────────────────────────── // FileReadRepository — read/query operations @@ -98,17 +98,14 @@ pub trait FileWriteRepository: Send + Sync + 'static { ) -> Result; /// Renames a file (same folder, different name). - async fn rename_file( - &self, - file_id: &str, - new_name: &str, - ) -> Result; + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; /// Deletes a file. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; /// Updates the content of an existing file. - async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError>; + async fn update_file_content(&self, file_id: &str, content: Vec) + -> Result<(), DomainError>; /// Registers file metadata WITHOUT writing content to disk (write-behind). /// @@ -128,7 +125,11 @@ pub trait FileWriteRepository: Send + Sync + 'static { async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; /// Restores a file from the trash to its original location - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError>; + async fn restore_from_trash( + &self, + file_id: &str, + original_path: &str, + ) -> Result<(), DomainError>; /// Permanently deletes a file (used by the trash) async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError>; diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 420faea2..9f5e267e 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -10,9 +10,9 @@ use async_trait::async_trait; +use crate::common::errors::DomainError; use crate::domain::entities::folder::Folder; use crate::domain::services::path_service::StoragePath; -use crate::common::errors::DomainError; /// Domain port for folder persistence. /// @@ -21,38 +21,46 @@ use crate::common::errors::DomainError; #[async_trait] pub trait FolderRepository: Send + Sync + 'static { /// Creates a new folder - async fn create_folder(&self, name: String, parent_id: Option) -> Result; - + async fn create_folder( + &self, + name: String, + parent_id: Option, + ) -> Result; + /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result; - + /// Gets a folder by its storage path async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result; - + /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - + /// Lists folders with pagination async fn list_folders_paginated( - &self, + &self, parent_id: Option<&str>, offset: usize, limit: usize, - include_total: bool + include_total: bool, ) -> Result<(Vec, Option), DomainError>; - + /// Renames a folder async fn rename_folder(&self, id: &str, new_name: String) -> Result; - + /// Moves a folder to another parent - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result; - + async fn move_folder( + &self, + id: &str, + new_parent_id: Option<&str>, + ) -> Result; + /// Deletes a folder async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; - + /// Checks if a folder exists at the given path async fn folder_exists(&self, storage_path: &StoragePath) -> Result; - + /// Gets the path of a folder async fn get_folder_path(&self, id: &str) -> Result; @@ -62,7 +70,11 @@ pub trait FolderRepository: Send + Sync + 'static { async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError>; /// Restores a folder from the trash to its original location - async fn restore_from_trash(&self, folder_id: &str, original_path: &str) -> Result<(), DomainError>; + async fn restore_from_trash( + &self, + folder_id: &str, + original_path: &str, + ) -> Result<(), DomainError>; /// Permanently deletes a folder (used by the trash) async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>; diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index 565a9325..ae8aec81 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -1,11 +1,11 @@ pub mod address_book_repository; -pub mod calendar_repository; pub mod calendar_event_repository; +pub mod calendar_repository; pub mod contact_repository; pub mod file_repository; pub mod folder_repository; pub mod session_repository; +pub mod settings_repository; pub mod share_repository; pub mod trash_repository; -pub mod settings_repository; -pub mod user_repository; \ No newline at end of file +pub mod user_repository; diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 0f87ab5d..6de796a8 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -1,15 +1,15 @@ -use async_trait::async_trait; -use crate::domain::entities::session::Session; use crate::common::errors::DomainError; +use crate::domain::entities::session::Session; +use async_trait::async_trait; #[derive(Debug, thiserror::Error)] pub enum SessionRepositoryError { #[error("Session not found: {0}")] NotFound(String), - + #[error("Database error: {0}")] DatabaseError(String), - + #[error("Timeout error: {0}")] Timeout(String), } @@ -20,15 +20,11 @@ pub type SessionRepositoryResult = Result; impl From for DomainError { fn from(err: SessionRepositoryError) -> Self { match err { - SessionRepositoryError::NotFound(msg) => { - DomainError::not_found("Session", msg) - }, + SessionRepositoryError::NotFound(msg) => DomainError::not_found("Session", msg), SessionRepositoryError::DatabaseError(msg) => { DomainError::internal_error("Database", msg) - }, - SessionRepositoryError::Timeout(msg) => { - DomainError::timeout("Database", msg) - }, + } + SessionRepositoryError::Timeout(msg) => DomainError::timeout("Database", msg), } } } @@ -37,22 +33,26 @@ impl From for DomainError { pub trait SessionRepository: Send + Sync + 'static { /// Creates a new session async fn create_session(&self, session: Session) -> SessionRepositoryResult; - + /// Gets a session by ID async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult; - + /// Gets a session by refresh token - async fn get_session_by_refresh_token(&self, refresh_token: &str) -> SessionRepositoryResult; - + async fn get_session_by_refresh_token( + &self, + refresh_token: &str, + ) -> SessionRepositoryResult; + /// Gets all sessions for a user - async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult>; - + async fn get_sessions_by_user_id(&self, user_id: &str) + -> SessionRepositoryResult>; + /// Revokes a specific session async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()>; - + /// Revokes all sessions for a user async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult; - + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; -} \ No newline at end of file +} diff --git a/src/domain/repositories/settings_repository.rs b/src/domain/repositories/settings_repository.rs index a99ae155..767cc309 100644 --- a/src/domain/repositories/settings_repository.rs +++ b/src/domain/repositories/settings_repository.rs @@ -1,27 +1,28 @@ -use std::collections::HashMap; -use async_trait::async_trait; -use crate::common::errors::DomainError; - -/// Repository for platform settings stored in the database. -/// Settings are key-value pairs organized by category (e.g., "oidc", "general"). -#[async_trait] -pub trait SettingsRepository: Send + Sync + 'static { - /// Get a single setting value by key - async fn get(&self, key: &str) -> Result, DomainError>; - - /// Get all settings for a given category - async fn get_by_category(&self, category: &str) -> Result, DomainError>; - - /// Set a setting value (upsert) - async fn set( - &self, - key: &str, - value: &str, - category: &str, - is_secret: bool, - updated_by: Option<&str>, - ) -> Result<(), DomainError>; - - /// Delete a setting by key - async fn delete(&self, key: &str) -> Result<(), DomainError>; -} +use crate::common::errors::DomainError; +use async_trait::async_trait; +use std::collections::HashMap; + +/// Repository for platform settings stored in the database. +/// Settings are key-value pairs organized by category (e.g., "oidc", "general"). +#[async_trait] +pub trait SettingsRepository: Send + Sync + 'static { + /// Get a single setting value by key + async fn get(&self, key: &str) -> Result, DomainError>; + + /// Get all settings for a given category + async fn get_by_category(&self, category: &str) + -> Result, DomainError>; + + /// Set a setting value (upsert) + async fn set( + &self, + key: &str, + value: &str, + category: &str, + is_secret: bool, + updated_by: Option<&str>, + ) -> Result<(), DomainError>; + + /// Delete a setting by key + async fn delete(&self, key: &str) -> Result<(), DomainError>; +} diff --git a/src/domain/repositories/share_repository.rs b/src/domain/repositories/share_repository.rs index f208b2bd..7ddd96b0 100644 --- a/src/domain/repositories/share_repository.rs +++ b/src/domain/repositories/share_repository.rs @@ -1,4 +1,3 @@ - use async_trait::async_trait; use thiserror::Error; @@ -25,22 +24,26 @@ pub enum ShareRepositoryError { pub trait ShareRepository: Send + Sync + 'static { /// Save a new share or update an existing one async fn save(&self, share: &Share) -> Result; - + /// Find a share by its ID async fn find_by_id(&self, id: &str) -> Result; - + /// Find a share by its token async fn find_by_token(&self, token: &str) -> Result; - + /// Find all shares for a specific item - async fn find_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result, ShareRepositoryError>; - + async fn find_by_item( + &self, + item_id: &str, + item_type: &ShareItemType, + ) -> Result, ShareRepositoryError>; + /// Delete a share by its ID async fn delete(&self, id: &str) -> Result<(), ShareRepositoryError>; - + /// Find all shares created by a specific user async fn find_by_user(&self, user_id: &str) -> Result, ShareRepositoryError>; - + /// Find all shares (admin operation) async fn find_all(&self) -> Result, ShareRepositoryError>; } diff --git a/src/domain/repositories/trash_repository.rs b/src/domain/repositories/trash_repository.rs index 32fb76ee..dfe7fe12 100644 --- a/src/domain/repositories/trash_repository.rs +++ b/src/domain/repositories/trash_repository.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use uuid::Uuid; -use crate::domain::entities::trashed_item::TrashedItem; use crate::common::errors::Result; +use crate::domain::entities::trashed_item::TrashedItem; #[async_trait] pub trait TrashRepository: Send + Sync { @@ -13,4 +13,4 @@ pub trait TrashRepository: Send + Sync { async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()>; async fn clear_trash(&self, user_id: &Uuid) -> Result<()>; async fn get_expired_items(&self) -> Result>; -} \ No newline at end of file +} diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 760375fa..369030d6 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -1,24 +1,24 @@ -use async_trait::async_trait; -use crate::domain::entities::user::{User, UserRole}; use crate::common::errors::DomainError; +use crate::domain::entities::user::{User, UserRole}; +use async_trait::async_trait; #[derive(Debug, thiserror::Error)] pub enum UserRepositoryError { #[error("User not found: {0}")] NotFound(String), - + #[error("User already exists: {0}")] AlreadyExists(String), - + #[error("Database error: {0}")] DatabaseError(String), - + #[error("Validation error: {0}")] ValidationError(String), - + #[error("Timeout error: {0}")] Timeout(String), - + #[error("Operation not allowed: {0}")] OperationNotAllowed(String), } @@ -29,24 +29,14 @@ pub type UserRepositoryResult = Result; impl From for DomainError { fn from(err: UserRepositoryError) -> Self { match err { - UserRepositoryError::NotFound(msg) => { - DomainError::not_found("User", msg) - }, - UserRepositoryError::AlreadyExists(msg) => { - DomainError::already_exists("User", msg) - }, - UserRepositoryError::DatabaseError(msg) => { - DomainError::internal_error("Database", msg) - }, - UserRepositoryError::ValidationError(msg) => { - DomainError::validation_error(msg) - }, - UserRepositoryError::Timeout(msg) => { - DomainError::timeout("Database", msg) - }, + UserRepositoryError::NotFound(msg) => DomainError::not_found("User", msg), + UserRepositoryError::AlreadyExists(msg) => DomainError::already_exists("User", msg), + UserRepositoryError::DatabaseError(msg) => DomainError::internal_error("Database", msg), + UserRepositoryError::ValidationError(msg) => DomainError::validation_error(msg), + UserRepositoryError::Timeout(msg) => DomainError::timeout("Database", msg), UserRepositoryError::OperationNotAllowed(msg) => { DomainError::access_denied("User", msg) - }, + } } } } @@ -55,48 +45,62 @@ impl From for DomainError { pub trait UserRepository: Send + Sync + 'static { /// Creates a new user async fn create_user(&self, user: User) -> UserRepositoryResult; - + /// Gets a user by ID async fn get_user_by_id(&self, id: &str) -> UserRepositoryResult; - + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult; - + /// Gets a user by email async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult; - + /// Updates an existing user async fn update_user(&self, user: User) -> UserRepositoryResult; - + /// Updates only a user's storage usage - async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> UserRepositoryResult<()>; - + async fn update_storage_usage( + &self, + user_id: &str, + usage_bytes: i64, + ) -> UserRepositoryResult<()>; + /// Updates the last login date async fn update_last_login(&self, user_id: &str) -> UserRepositoryResult<()>; - + /// Lists users with pagination async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult>; - + /// Activates or deactivates a user - async fn set_user_active_status(&self, user_id: &str, active: bool) -> UserRepositoryResult<()>; - + async fn set_user_active_status(&self, user_id: &str, active: bool) + -> UserRepositoryResult<()>; + /// Changes a user's password - async fn change_password(&self, user_id: &str, password_hash: &str) -> UserRepositoryResult<()>; - + async fn change_password(&self, user_id: &str, password_hash: &str) + -> UserRepositoryResult<()>; + /// Changes a user's role async fn change_role(&self, user_id: &str, role: UserRole) -> UserRepositoryResult<()>; - + /// Lists users by role (admin or user) async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult>; - + /// Deletes a user async fn delete_user(&self, user_id: &str) -> UserRepositoryResult<()>; /// Finds a user by OIDC provider + subject pair - async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult; + async fn get_user_by_oidc_subject( + &self, + provider: &str, + subject: &str, + ) -> UserRepositoryResult; /// Updates a user's storage quota - async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> UserRepositoryResult<()>; + async fn update_storage_quota( + &self, + user_id: &str, + quota_bytes: i64, + ) -> UserRepositoryResult<()>; /// Counts the total number of users async fn count_users(&self) -> UserRepositoryResult; @@ -114,4 +118,4 @@ pub struct StorageStats { pub total_used_bytes: i64, pub users_over_80_percent: i64, pub users_over_quota: i64, -} \ No newline at end of file +} diff --git a/src/domain/services/i18n_service.rs b/src/domain/services/i18n_service.rs index 8e79e37d..00f8384e 100644 --- a/src/domain/services/i18n_service.rs +++ b/src/domain/services/i18n_service.rs @@ -6,10 +6,10 @@ use thiserror::Error; pub enum I18nError { #[error("Translation key not found: {0}")] KeyNotFound(String), - + #[error("Invalid locale: {0}")] InvalidLocale(String), - + #[error("Error loading translations: {0}")] LoadError(String), } @@ -38,7 +38,7 @@ impl Locale { Locale::Portuguese => "pt", } } - + /// Create from locale code string pub fn from_str(code: &str) -> Option { match code.to_lowercase().as_str() { @@ -50,7 +50,7 @@ impl Locale { _ => None, } } - + /// Get default locale pub fn default() -> Self { Locale::English @@ -62,13 +62,13 @@ impl Locale { pub trait I18nService: Send + Sync + 'static { /// Get a translation for a key and locale async fn translate(&self, key: &str, locale: Locale) -> I18nResult; - + /// Load translations for a locale async fn load_translations(&self, locale: Locale) -> I18nResult<()>; - + /// Get available locales async fn available_locales(&self) -> Vec; - + /// Check if a locale is supported async fn is_supported(&self, locale: Locale) -> bool; -} \ No newline at end of file +} diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index b201ea54..83d4df8f 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -2,4 +2,4 @@ pub mod i18n_service; pub mod path_service; // NOTE: auth_service has been moved to infrastructure/services/jwt_service.rs -// The functionality is now exposed through application/ports/auth_ports.rs (TokenServicePort) \ No newline at end of file +// The functionality is now exposed through application/ports/auth_ports.rs (TokenServicePort) diff --git a/src/domain/services/path_service.rs b/src/domain/services/path_service.rs index c6b89b17..9ddfd155 100644 --- a/src/domain/services/path_service.rs +++ b/src/domain/services/path_service.rs @@ -1,7 +1,7 @@ //! StoragePath - Domain Value Object for representing storage paths -//! +//! //! This module contains only the StoragePath Value Object which is part of the pure domain. -//! PathService (which implements StoragePort and StorageMediator) was moved to +//! PathService (which implements StoragePort and StorageMediator) was moved to //! infrastructure/services/path_service.rs because it has file system dependencies. use std::path::PathBuf; @@ -17,12 +17,14 @@ impl StoragePath { pub fn new(segments: Vec) -> Self { Self { segments } } - + /// Creates an empty path (root) pub fn root() -> Self { - Self { segments: Vec::new() } + Self { + segments: Vec::new(), + } } - + /// Creates a path from a string with segments separated by / pub fn from_string(path: &str) -> Self { let segments = path @@ -32,7 +34,7 @@ impl StoragePath { .collect(); Self { segments } } - + /// Creates a path from a PathBuf pub fn from(path_buf: PathBuf) -> Self { let segments = path_buf @@ -44,34 +46,38 @@ impl StoragePath { .collect(); Self { segments } } - + /// Appends a segment to the path pub fn join(&self, segment: &str) -> Self { let mut new_segments = self.segments.clone(); new_segments.push(segment.to_string()); - Self { segments: new_segments } + Self { + segments: new_segments, + } } - + /// Gets the file name (last segment) pub fn file_name(&self) -> Option { self.segments.last().cloned() } - + /// Gets the parent directory path pub fn parent(&self) -> Option { if self.segments.is_empty() { None } else { let parent_segments = self.segments[..self.segments.len() - 1].to_vec(); - Some(Self { segments: parent_segments }) + Some(Self { + segments: parent_segments, + }) } } - + /// Checks if the path is empty (is the root) pub fn is_empty(&self) -> bool { self.segments.is_empty() } - + /// Converts the path to a string with format "/segment1/segment2/..." pub fn to_string(&self) -> String { if self.segments.is_empty() { @@ -80,7 +86,7 @@ impl StoragePath { format!("/{}", self.segments.join("/")) } } - + /// Returns the path representation as a string pub fn as_str(&self) -> &str { // Note: The implementation should really store the string, @@ -88,7 +94,7 @@ impl StoragePath { // This is only used for the get_folder_path_str implementation "/" } - + /// Gets the path segments pub fn segments(&self) -> &[String] { &self.segments @@ -98,38 +104,38 @@ impl StoragePath { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_storage_path_from_string() { let path = StoragePath::from_string("folder/subfolder/file.txt"); assert_eq!(path.segments(), &["folder", "subfolder", "file.txt"]); assert_eq!(path.to_string(), "/folder/subfolder/file.txt"); } - + #[test] fn test_storage_path_join() { let path = StoragePath::from_string("folder"); let joined = path.join("file.txt"); assert_eq!(joined.to_string(), "/folder/file.txt"); } - + #[test] fn test_storage_path_parent() { let path = StoragePath::from_string("folder/file.txt"); let parent = path.parent().unwrap(); assert_eq!(parent.to_string(), "/folder"); } - + #[test] fn test_storage_path_root() { let root = StoragePath::root(); assert!(root.is_empty()); assert_eq!(root.to_string(), "/"); } - + #[test] fn test_storage_path_file_name() { let path = StoragePath::from_string("folder/file.txt"); assert_eq!(path.file_name(), Some("file.txt".to_string())); } -} \ No newline at end of file +} diff --git a/src/infrastructure/adapters/calendar_storage_adapter.rs b/src/infrastructure/adapters/calendar_storage_adapter.rs index 3121e2c2..0281a547 100644 --- a/src/infrastructure/adapters/calendar_storage_adapter.rs +++ b/src/infrastructure/adapters/calendar_storage_adapter.rs @@ -1,298 +1,467 @@ -//! Calendar Storage Adapter -//! -//! This adapter implements the `CalendarStoragePort` application port using -//! the `CalendarRepository` and `CalendarEventRepository` domain repositories. -//! It bridges the gap between the application layer and the infrastructure layer. - -use std::sync::Arc; -use std::collections::HashMap; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use uuid::Uuid; - -use crate::application::dtos::calendar_dto::{ - CalendarDto, CalendarEventDto, CreateCalendarDto, UpdateCalendarDto, - CreateEventDto, UpdateEventDto, CreateEventICalDto -}; -use crate::application::ports::calendar_ports::CalendarStoragePort; -use crate::common::errors::{DomainError, ErrorKind}; -use crate::domain::entities::calendar::Calendar; -use crate::domain::entities::calendar_event::CalendarEvent; -use crate::domain::repositories::calendar_repository::CalendarRepository; -use crate::domain::repositories::calendar_event_repository::CalendarEventRepository; - -/// Adapter that implements CalendarStoragePort using domain repositories -pub struct CalendarStorageAdapter { - calendar_repository: Arc, - event_repository: Arc, -} - -impl CalendarStorageAdapter { - /// Creates a new CalendarStorageAdapter with the given repositories - pub fn new( - calendar_repository: Arc, - event_repository: Arc, - ) -> Self { - Self { - calendar_repository, - event_repository, - } - } -} - -#[async_trait] -impl CalendarStoragePort for CalendarStorageAdapter { - // Calendar operations - - async fn create_calendar(&self, dto: CreateCalendarDto, owner_id: &str) -> Result { - let calendar = Calendar::new( - dto.name, - owner_id.to_string(), - dto.description, - dto.color, - )?; - - let created = self.calendar_repository.create_calendar(calendar).await?; - Ok(CalendarDto::from(created)) - } - - async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - let mut calendar = self.calendar_repository.find_calendar_by_id(&uuid).await?; - - if let Some(name) = update.name { - calendar.update_name(name)?; - } - if let Some(description) = update.description { - calendar.update_description(Some(description)); - } - if let Some(color) = update.color { - calendar.update_color(Some(color))?; - } - - let updated = self.calendar_repository.update_calendar(calendar).await?; - Ok(CalendarDto::from(updated)) - } - - async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - // First delete all events in the calendar - self.event_repository.delete_all_events_in_calendar(&uuid).await?; - - // Then delete the calendar itself - self.calendar_repository.delete_calendar(&uuid).await - } - - async fn get_calendar(&self, calendar_id: &str) -> Result { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - let calendar = self.calendar_repository.find_calendar_by_id(&uuid).await?; - Ok(CalendarDto::from(calendar)) - } - - async fn list_calendars_by_owner(&self, owner_id: &str) -> Result, DomainError> { - let calendars = self.calendar_repository.list_calendars_by_owner(owner_id).await?; - Ok(calendars.into_iter().map(CalendarDto::from).collect()) - } - - async fn list_calendars_shared_with_user(&self, user_id: &str) -> Result, DomainError> { - let calendars = self.calendar_repository.list_calendars_shared_with_user(user_id).await?; - Ok(calendars.into_iter().map(CalendarDto::from).collect()) - } - - async fn list_public_calendars(&self, limit: i64, offset: i64) -> Result, DomainError> { - let calendars = self.calendar_repository.list_public_calendars(limit, offset).await?; - Ok(calendars.into_iter().map(CalendarDto::from).collect()) - } - - async fn check_calendar_access(&self, calendar_id: &str, user_id: &str) -> Result { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.user_has_calendar_access(&uuid, user_id).await - } - - // Calendar sharing - - async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.share_calendar(&uuid, user_id, access_level).await - } - - async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.remove_calendar_sharing(&uuid, user_id).await - } - - async fn get_calendar_shares(&self, calendar_id: &str) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.get_calendar_shares(&uuid).await - } - - // Calendar properties - - async fn set_calendar_property(&self, calendar_id: &str, property_name: &str, property_value: &str) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.set_calendar_property(&uuid, property_name, property_value).await - } - - async fn get_calendar_property(&self, calendar_id: &str, property_name: &str) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.get_calendar_property(&uuid, property_name).await - } - - async fn get_calendar_properties(&self, calendar_id: &str) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - self.calendar_repository.get_calendar_properties(&uuid).await - } - - // Event operations - - async fn create_event(&self, dto: CreateEventDto) -> Result { - let calendar_id = Uuid::parse_str(&dto.calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid calendar ID format"))?; - - // Verify calendar exists and user has access - let _calendar = self.calendar_repository.find_calendar_by_id(&calendar_id).await?; - - // Generate basic iCal data - let ical_data = format!( - "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//OxiCloud//EN\nBEGIN:VEVENT\nUID:{}@oxicloud\nDTSTAMP:{}\nDTSTART:{}\nDTEND:{}\nSUMMARY:{}\nEND:VEVENT\nEND:VCALENDAR", - uuid::Uuid::new_v4(), - chrono::Utc::now().format("%Y%m%dT%H%M%SZ"), - dto.start_time.format("%Y%m%dT%H%M%SZ"), - dto.end_time.format("%Y%m%dT%H%M%SZ"), - dto.summary - ); - - let event = CalendarEvent::new( - calendar_id, - dto.summary, - dto.description, - dto.location, - dto.start_time, - dto.end_time, - dto.all_day.unwrap_or(false), - dto.rrule, - ical_data, - )?; - - let created = self.event_repository.create_event(event).await?; - Ok(CalendarEventDto::from(created)) - } - - async fn create_event_from_ical(&self, dto: CreateEventICalDto) -> Result { - let calendar_id = Uuid::parse_str(&dto.calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid calendar ID format"))?; - - // Verify calendar exists - let _calendar = self.calendar_repository.find_calendar_by_id(&calendar_id).await?; - - // Parse iCal data and create event - let event = CalendarEvent::from_ical(calendar_id, dto.ical_data.clone())?; - - let created = self.event_repository.create_event(event).await?; - Ok(CalendarEventDto::from(created)) - } - - async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result { - let uuid = Uuid::parse_str(event_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid event ID format"))?; - - let mut event = self.event_repository.find_event_by_id(&uuid).await?; - - if let Some(summary) = update.summary { - event.update_summary(summary)?; - } - if let Some(description) = update.description { - event.update_description(Some(description)); - } - if let Some(location) = update.location { - event.update_location(Some(location)); - } - if let Some(start_time) = update.start_time { - if let Some(end_time) = update.end_time { - event.update_time_range(start_time, end_time)?; - } else { - event.update_time_range(start_time, *event.end_time())?; - } - } else if let Some(end_time) = update.end_time { - event.update_time_range(*event.start_time(), end_time)?; - } - if let Some(all_day) = update.all_day { - event.update_all_day(all_day); - } - if let Some(rrule) = update.rrule { - event.update_rrule(Some(rrule))?; - } - - let updated = self.event_repository.update_event(event).await?; - Ok(CalendarEventDto::from(updated)) - } - - async fn delete_event(&self, event_id: &str) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(event_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid event ID format"))?; - - self.event_repository.delete_event(&uuid).await - } - - async fn get_event(&self, event_id: &str) -> Result { - let uuid = Uuid::parse_str(event_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid event ID format"))?; - - let event = self.event_repository.find_event_by_id(&uuid).await?; - Ok(CalendarEventDto::from(event)) - } - - async fn list_events_by_calendar(&self, calendar_id: &str) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - let events = self.event_repository.list_events_by_calendar(&uuid).await?; - Ok(events.into_iter().map(CalendarEventDto::from).collect()) - } - - async fn list_events_by_calendar_paginated(&self, calendar_id: &str, limit: i64, offset: i64) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - let events = self.event_repository.list_events_by_calendar_paginated(&uuid, limit, offset).await?; - Ok(events.into_iter().map(CalendarEventDto::from).collect()) - } - - async fn get_events_in_time_range( - &self, - calendar_id: &str, - start: &DateTime, - end: &DateTime - ) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid calendar ID format"))?; - - let events = self.event_repository.get_events_in_time_range(&uuid, start, end).await?; - Ok(events.into_iter().map(CalendarEventDto::from).collect()) - } -} - -#[cfg(test)] -mod tests { - // Tests would go here using mock repositories -} +//! Calendar Storage Adapter +//! +//! This adapter implements the `CalendarStoragePort` application port using +//! the `CalendarRepository` and `CalendarEventRepository` domain repositories. +//! It bridges the gap between the application layer and the infrastructure layer. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +use crate::application::dtos::calendar_dto::{ + CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto, + UpdateCalendarDto, UpdateEventDto, +}; +use crate::application::ports::calendar_ports::CalendarStoragePort; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::calendar::Calendar; +use crate::domain::entities::calendar_event::CalendarEvent; +use crate::domain::repositories::calendar_event_repository::CalendarEventRepository; +use crate::domain::repositories::calendar_repository::CalendarRepository; + +/// Adapter that implements CalendarStoragePort using domain repositories +pub struct CalendarStorageAdapter { + calendar_repository: Arc, + event_repository: Arc, +} + +impl CalendarStorageAdapter { + /// Creates a new CalendarStorageAdapter with the given repositories + pub fn new( + calendar_repository: Arc, + event_repository: Arc, + ) -> Self { + Self { + calendar_repository, + event_repository, + } + } +} + +#[async_trait] +impl CalendarStoragePort for CalendarStorageAdapter { + // Calendar operations + + async fn create_calendar( + &self, + dto: CreateCalendarDto, + owner_id: &str, + ) -> Result { + let calendar = Calendar::new(dto.name, owner_id.to_string(), dto.description, dto.color)?; + + let created = self.calendar_repository.create_calendar(calendar).await?; + Ok(CalendarDto::from(created)) + } + + async fn update_calendar( + &self, + calendar_id: &str, + update: UpdateCalendarDto, + ) -> Result { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + let mut calendar = self.calendar_repository.find_calendar_by_id(&uuid).await?; + + if let Some(name) = update.name { + calendar.update_name(name)?; + } + if let Some(description) = update.description { + calendar.update_description(Some(description)); + } + if let Some(color) = update.color { + calendar.update_color(Some(color))?; + } + + let updated = self.calendar_repository.update_calendar(calendar).await?; + Ok(CalendarDto::from(updated)) + } + + async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + // First delete all events in the calendar + self.event_repository + .delete_all_events_in_calendar(&uuid) + .await?; + + // Then delete the calendar itself + self.calendar_repository.delete_calendar(&uuid).await + } + + async fn get_calendar(&self, calendar_id: &str) -> Result { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + let calendar = self.calendar_repository.find_calendar_by_id(&uuid).await?; + Ok(CalendarDto::from(calendar)) + } + + async fn list_calendars_by_owner( + &self, + owner_id: &str, + ) -> Result, DomainError> { + let calendars = self + .calendar_repository + .list_calendars_by_owner(owner_id) + .await?; + Ok(calendars.into_iter().map(CalendarDto::from).collect()) + } + + async fn list_calendars_shared_with_user( + &self, + user_id: &str, + ) -> Result, DomainError> { + let calendars = self + .calendar_repository + .list_calendars_shared_with_user(user_id) + .await?; + Ok(calendars.into_iter().map(CalendarDto::from).collect()) + } + + async fn list_public_calendars( + &self, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let calendars = self + .calendar_repository + .list_public_calendars(limit, offset) + .await?; + Ok(calendars.into_iter().map(CalendarDto::from).collect()) + } + + async fn check_calendar_access( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository + .user_has_calendar_access(&uuid, user_id) + .await + } + + // Calendar sharing + + async fn share_calendar( + &self, + calendar_id: &str, + user_id: &str, + access_level: &str, + ) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository + .share_calendar(&uuid, user_id, access_level) + .await + } + + async fn remove_calendar_sharing( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository + .remove_calendar_sharing(&uuid, user_id) + .await + } + + async fn get_calendar_shares( + &self, + calendar_id: &str, + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository.get_calendar_shares(&uuid).await + } + + // Calendar properties + + async fn set_calendar_property( + &self, + calendar_id: &str, + property_name: &str, + property_value: &str, + ) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository + .set_calendar_property(&uuid, property_name, property_value) + .await + } + + async fn get_calendar_property( + &self, + calendar_id: &str, + property_name: &str, + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository + .get_calendar_property(&uuid, property_name) + .await + } + + async fn get_calendar_properties( + &self, + calendar_id: &str, + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + self.calendar_repository + .get_calendar_properties(&uuid) + .await + } + + // Event operations + + async fn create_event(&self, dto: CreateEventDto) -> Result { + let calendar_id = Uuid::parse_str(&dto.calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Event", + "Invalid calendar ID format", + ) + })?; + + // Verify calendar exists and user has access + let _calendar = self + .calendar_repository + .find_calendar_by_id(&calendar_id) + .await?; + + // Generate basic iCal data + let ical_data = format!( + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//OxiCloud//EN\nBEGIN:VEVENT\nUID:{}@oxicloud\nDTSTAMP:{}\nDTSTART:{}\nDTEND:{}\nSUMMARY:{}\nEND:VEVENT\nEND:VCALENDAR", + uuid::Uuid::new_v4(), + chrono::Utc::now().format("%Y%m%dT%H%M%SZ"), + dto.start_time.format("%Y%m%dT%H%M%SZ"), + dto.end_time.format("%Y%m%dT%H%M%SZ"), + dto.summary + ); + + let event = CalendarEvent::new( + calendar_id, + dto.summary, + dto.description, + dto.location, + dto.start_time, + dto.end_time, + dto.all_day.unwrap_or(false), + dto.rrule, + ical_data, + )?; + + let created = self.event_repository.create_event(event).await?; + Ok(CalendarEventDto::from(created)) + } + + async fn create_event_from_ical( + &self, + dto: CreateEventICalDto, + ) -> Result { + let calendar_id = Uuid::parse_str(&dto.calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Event", + "Invalid calendar ID format", + ) + })?; + + // Verify calendar exists + let _calendar = self + .calendar_repository + .find_calendar_by_id(&calendar_id) + .await?; + + // Parse iCal data and create event + let event = CalendarEvent::from_ical(calendar_id, dto.ical_data.clone())?; + + let created = self.event_repository.create_event(event).await?; + Ok(CalendarEventDto::from(created)) + } + + async fn update_event( + &self, + event_id: &str, + update: UpdateEventDto, + ) -> Result { + let uuid = Uuid::parse_str(event_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid event ID format") + })?; + + let mut event = self.event_repository.find_event_by_id(&uuid).await?; + + if let Some(summary) = update.summary { + event.update_summary(summary)?; + } + if let Some(description) = update.description { + event.update_description(Some(description)); + } + if let Some(location) = update.location { + event.update_location(Some(location)); + } + if let Some(start_time) = update.start_time { + if let Some(end_time) = update.end_time { + event.update_time_range(start_time, end_time)?; + } else { + event.update_time_range(start_time, *event.end_time())?; + } + } else if let Some(end_time) = update.end_time { + event.update_time_range(*event.start_time(), end_time)?; + } + if let Some(all_day) = update.all_day { + event.update_all_day(all_day); + } + if let Some(rrule) = update.rrule { + event.update_rrule(Some(rrule))?; + } + + let updated = self.event_repository.update_event(event).await?; + Ok(CalendarEventDto::from(updated)) + } + + async fn delete_event(&self, event_id: &str) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(event_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid event ID format") + })?; + + self.event_repository.delete_event(&uuid).await + } + + async fn get_event(&self, event_id: &str) -> Result { + let uuid = Uuid::parse_str(event_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Event", "Invalid event ID format") + })?; + + let event = self.event_repository.find_event_by_id(&uuid).await?; + Ok(CalendarEventDto::from(event)) + } + + async fn list_events_by_calendar( + &self, + calendar_id: &str, + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + let events = self.event_repository.list_events_by_calendar(&uuid).await?; + Ok(events.into_iter().map(CalendarEventDto::from).collect()) + } + + async fn list_events_by_calendar_paginated( + &self, + calendar_id: &str, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + let events = self + .event_repository + .list_events_by_calendar_paginated(&uuid, limit, offset) + .await?; + Ok(events.into_iter().map(CalendarEventDto::from).collect()) + } + + async fn get_events_in_time_range( + &self, + calendar_id: &str, + start: &DateTime, + end: &DateTime, + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + let events = self + .event_repository + .get_events_in_time_range(&uuid, start, end) + .await?; + Ok(events.into_iter().map(CalendarEventDto::from).collect()) + } +} + +#[cfg(test)] +mod tests { + // Tests would go here using mock repositories +} diff --git a/src/infrastructure/adapters/contact_storage_adapter.rs b/src/infrastructure/adapters/contact_storage_adapter.rs index a504a9e1..05c110d0 100644 --- a/src/infrastructure/adapters/contact_storage_adapter.rs +++ b/src/infrastructure/adapters/contact_storage_adapter.rs @@ -1,704 +1,950 @@ -//! Contact Storage Adapter -//! -//! This adapter implements the `AddressBookUseCase` and `ContactUseCase` application ports -//! using the domain repositories. It bridges the gap between the application layer -//! and the infrastructure layer for CardDAV functionality. - -use std::sync::Arc; -use async_trait::async_trait; -use uuid::Uuid; - -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, - EmailDto, PhoneDto, AddressDto -}; -use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; -use crate::common::errors::{DomainError, ErrorKind}; -use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup, Email, Phone, Address}; -use crate::domain::repositories::address_book_repository::AddressBookRepository; -use crate::domain::repositories::contact_repository::{ContactRepository, ContactGroupRepository}; - -/// Adapter that implements AddressBookUseCase and ContactUseCase using domain repositories -pub struct ContactStorageAdapter { - address_book_repository: Arc, - contact_repository: Arc, - group_repository: Arc, -} - -impl ContactStorageAdapter { - /// Creates a new ContactStorageAdapter with the given repositories - pub fn new( - address_book_repository: Arc, - contact_repository: Arc, - group_repository: Arc, - ) -> Self { - Self { - address_book_repository, - contact_repository, - group_repository, - } - } - - /// Helper to parse UUID from string - fn parse_uuid(id: &str, entity_name: &'static str) -> Result { - Uuid::parse_str(id) - .map_err(|_| DomainError::new(ErrorKind::InvalidInput, entity_name, format!("Invalid {} ID format", entity_name))) - } - - /// Helper to check if user has access to an address book - async fn check_address_book_access(&self, address_book_id: &Uuid, user_id: &str) -> Result { - let address_book = self.address_book_repository - .get_address_book_by_id(address_book_id) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - - // Check if user is owner - if address_book.owner_id() == user_id { - return Ok(address_book); - } - - // Check if address book is public - if address_book.is_public() { - return Ok(address_book); - } - - // Check if address book is shared with user - let shares = self.address_book_repository.get_address_book_shares(address_book_id).await?; - if shares.iter().any(|(shared_user, _)| shared_user == user_id) { - return Ok(address_book); - } - - Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Access denied to address book")) - } - - /// Helper to check write access - async fn check_write_access(&self, address_book_id: &Uuid, user_id: &str) -> Result { - let address_book = self.address_book_repository - .get_address_book_by_id(address_book_id) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - - // Owner always has write access - if address_book.owner_id() == user_id { - return Ok(address_book); - } - - // Check shares for write permission - let shares = self.address_book_repository.get_address_book_shares(address_book_id).await?; - if shares.iter().any(|(shared_user, can_write)| shared_user == user_id && *can_write) { - return Ok(address_book); - } - - Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Write access denied")) - } - - /// Convert EmailDto to domain Email - fn dto_to_email(dto: EmailDto) -> Email { - Email { - email: dto.email, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } - - /// Convert PhoneDto to domain Phone - fn dto_to_phone(dto: PhoneDto) -> Phone { - Phone { - number: dto.number, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } - - /// Convert AddressDto to domain Address - fn dto_to_address(dto: AddressDto) -> Address { - Address { - street: dto.street, - city: dto.city, - state: dto.state, - postal_code: dto.postal_code, - country: dto.country, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } - - /// Generate vCard from contact data - fn generate_vcard(contact: &Contact) -> String { - let mut vcard = String::from("BEGIN:VCARD\nVERSION:3.0\n"); - - if let Some(full_name) = contact.full_name() { - vcard.push_str(&format!("FN:{}\n", full_name)); - } - - if contact.first_name().is_some() || contact.last_name().is_some() { - let last = contact.last_name().unwrap_or(""); - let first = contact.first_name().unwrap_or(""); - vcard.push_str(&format!("N:{};{};;;\n", last, first)); - } - - if let Some(nickname) = contact.nickname() { - vcard.push_str(&format!("NICKNAME:{}\n", nickname)); - } - - for email in contact.email() { - vcard.push_str(&format!("EMAIL;TYPE={}:{}\n", email.r#type.to_uppercase(), email.email)); - } - - for phone in contact.phone() { - vcard.push_str(&format!("TEL;TYPE={}:{}\n", phone.r#type.to_uppercase(), phone.number)); - } - - if let Some(org) = contact.organization() { - vcard.push_str(&format!("ORG:{}\n", org)); - } - - if let Some(title) = contact.title() { - vcard.push_str(&format!("TITLE:{}\n", title)); - } - - if let Some(notes) = contact.notes() { - vcard.push_str(&format!("NOTE:{}\n", notes)); - } - - vcard.push_str(&format!("UID:{}\n", contact.uid())); - vcard.push_str("END:VCARD\n"); - - vcard - } -} - -#[async_trait] -impl AddressBookUseCase for ContactStorageAdapter { - async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result { - let address_book = AddressBook::new( - dto.name, - dto.owner_id, - dto.description, - dto.color, - dto.is_public.unwrap_or(false), - ); - - let created = self.address_book_repository.create_address_book(address_book).await?; - Ok(AddressBookDto::from(created)) - } - - async fn update_address_book(&self, address_book_id: &str, update: UpdateAddressBookDto) -> Result { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check write access - let mut address_book = self.check_write_access(&uuid, &update.user_id).await?; - - if let Some(name) = update.name { - address_book.set_name(name); - } - if let Some(description) = update.description { - address_book.set_description(Some(description)); - } - if let Some(color) = update.color { - address_book.set_color(Some(color)); - } - if let Some(is_public) = update.is_public { - address_book.set_is_public(is_public); - } - address_book.set_updated_at(chrono::Utc::now()); - - let updated = self.address_book_repository.update_address_book(address_book).await?; - Ok(AddressBookDto::from(updated)) - } - - async fn delete_address_book(&self, address_book_id: &str, user_id: &str) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Only owner can delete - let address_book = self.address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - - if address_book.owner_id() != user_id { - return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can delete address book")); - } - - self.address_book_repository.delete_address_book(&uuid).await - } - - async fn get_address_book(&self, address_book_id: &str, user_id: &str) -> Result { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - let address_book = self.check_address_book_access(&uuid, user_id).await?; - Ok(AddressBookDto::from(address_book)) - } - - async fn list_user_address_books(&self, user_id: &str) -> Result, DomainError> { - let owned = self.address_book_repository.get_address_books_by_owner(user_id).await?; - let shared = self.address_book_repository.get_shared_address_books(user_id).await?; - - let mut all_books: Vec = owned; - all_books.extend(shared); - - Ok(all_books.into_iter().map(AddressBookDto::from).collect()) - } - - async fn list_public_address_books(&self) -> Result, DomainError> { - let public = self.address_book_repository.get_public_address_books().await?; - Ok(public.into_iter().map(AddressBookDto::from).collect()) - } - - async fn share_address_book(&self, dto: ShareAddressBookDto, user_id: &str) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Only owner can share - let address_book = self.address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - - if address_book.owner_id() != user_id { - return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can share")); - } - - self.address_book_repository.share_address_book(&uuid, &dto.user_id, dto.can_write).await - } - - async fn unshare_address_book(&self, dto: UnshareAddressBookDto, user_id: &str) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Only owner can unshare - let address_book = self.address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - - if address_book.owner_id() != user_id { - return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can unshare")); - } - - self.address_book_repository.unshare_address_book(&uuid, &dto.user_id).await - } - - async fn get_address_book_shares(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Only owner can view shares - let address_book = self.address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found"))?; - - if address_book.owner_id() != user_id { - return Err(DomainError::new(ErrorKind::AccessDenied, "AddressBook", "Only owner can view shares")); - } - - self.address_book_repository.get_address_book_shares(&uuid).await - } -} - -#[async_trait] -impl ContactUseCase for ContactStorageAdapter { - async fn create_contact(&self, dto: CreateContactDto) -> Result { - let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Check write access - self.check_write_access(&address_book_id, &dto.user_id).await?; - - let now = chrono::Utc::now(); - let mut contact = Contact::from_raw( - Uuid::new_v4(), - address_book_id, - format!("{}@oxicloud", Uuid::new_v4()), - dto.full_name, - dto.first_name, - dto.last_name, - dto.nickname, - dto.email.into_iter().map(Self::dto_to_email).collect(), - dto.phone.into_iter().map(Self::dto_to_phone).collect(), - dto.address.into_iter().map(Self::dto_to_address).collect(), - dto.organization, - dto.title, - dto.notes, - dto.photo_url, - dto.birthday, - dto.anniversary, - String::new(), - Uuid::new_v4().to_string(), - now, - now, - ); - - // Generate vCard - let vcard = Self::generate_vcard(&contact); - contact.set_vcard(vcard); - - let created = self.contact_repository.create_contact(contact).await?; - Ok(ContactDto::from(created)) - } - - async fn create_contact_from_vcard(&self, dto: CreateContactVCardDto) -> Result { - let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Check write access - self.check_write_access(&address_book_id, &dto.user_id).await?; - - // 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) - && !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") - && let Some(value) = trimmed.split(':').nth(1) - && !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, - 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, - now, - ); - - let created = self.contact_repository.create_contact(contact).await?; - Ok(ContactDto::from(created)) - } - - async fn update_contact(&self, contact_id: &str, update: UpdateContactDto) -> Result { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let mut contact = self.contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check write access to the address book - self.check_write_access(contact.address_book_id(), &update.user_id).await?; - - if let Some(full_name) = update.full_name { - contact.set_full_name(Some(full_name)); - } - if let Some(first_name) = update.first_name { - contact.set_first_name(Some(first_name)); - } - if let Some(last_name) = update.last_name { - contact.set_last_name(Some(last_name)); - } - if let Some(nickname) = update.nickname { - contact.set_nickname(Some(nickname)); - } - if let Some(emails) = update.email { - contact.set_email(emails.into_iter().map(Self::dto_to_email).collect()); - } - if let Some(phones) = update.phone { - contact.set_phone(phones.into_iter().map(Self::dto_to_phone).collect()); - } - if let Some(addresses) = update.address { - contact.set_address(addresses.into_iter().map(Self::dto_to_address).collect()); - } - if let Some(organization) = update.organization { - contact.set_organization(Some(organization)); - } - if let Some(title) = update.title { - contact.set_title(Some(title)); - } - if let Some(notes) = update.notes { - contact.set_notes(Some(notes)); - } - if let Some(photo_url) = update.photo_url { - contact.set_photo_url(Some(photo_url)); - } - if let Some(birthday) = update.birthday { - contact.set_birthday(Some(birthday)); - } - if let Some(anniversary) = update.anniversary { - contact.set_anniversary(Some(anniversary)); - } - - contact.set_updated_at(chrono::Utc::now()); - contact.set_etag(Uuid::new_v4().to_string()); - let vcard = Self::generate_vcard(&contact); - contact.set_vcard(vcard); - - let updated = self.contact_repository.update_contact(contact).await?; - Ok(ContactDto::from(updated)) - } - - async fn delete_contact(&self, contact_id: &str, user_id: &str) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self.contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check write access - self.check_write_access(contact.address_book_id(), user_id).await?; - - self.contact_repository.delete_contact(&uuid).await - } - - async fn get_contact(&self, contact_id: &str, user_id: &str) -> Result { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self.contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check read access - self.check_address_book_access(contact.address_book_id(), user_id).await?; - - Ok(ContactDto::from(contact)) - } - - async fn list_contacts(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contacts = self.contact_repository.get_contacts_by_address_book(&uuid).await?; - Ok(contacts.into_iter().map(ContactDto::from).collect()) - } - - async fn search_contacts(&self, address_book_id: &str, query: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contacts = self.contact_repository.search_contacts(&uuid, query).await?; - Ok(contacts.into_iter().map(ContactDto::from).collect()) - } - - async fn create_group(&self, dto: CreateContactGroupDto) -> Result { - let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Check write access - self.check_write_access(&address_book_id, &dto.user_id).await?; - - let group = ContactGroup::new( - address_book_id, - dto.name, - ); - - let created = self.group_repository.create_group(group).await?; - Ok(ContactGroupDto::from(created)) - } - - async fn update_group(&self, group_id: &str, update: UpdateContactGroupDto) -> Result { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let mut group = self.group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; - - // Check write access - self.check_write_access(group.address_book_id(), &update.user_id).await?; - - group.set_name(update.name); - group.set_updated_at(chrono::Utc::now()); - - let updated = self.group_repository.update_group(group).await?; - Ok(ContactGroupDto::from(updated)) - } - - async fn delete_group(&self, group_id: &str, user_id: &str) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let group = self.group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; - - // Check write access - self.check_write_access(group.address_book_id(), user_id).await?; - - self.group_repository.delete_group(&uuid).await - } - - async fn get_group(&self, group_id: &str, user_id: &str) -> Result { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let group = self.group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; - - // Check read access - self.check_address_book_access(group.address_book_id(), user_id).await?; - - Ok(ContactGroupDto::from(group)) - } - - async fn list_groups(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let groups = self.group_repository.get_groups_by_address_book(&uuid).await?; - Ok(groups.into_iter().map(ContactGroupDto::from).collect()) - } - - async fn add_contact_to_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError> { - let group_id = Self::parse_uuid(&dto.group_id, "ContactGroup")?; - let contact_id = Self::parse_uuid(&dto.contact_id, "Contact")?; - - let group = self.group_repository - .get_group_by_id(&group_id) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; - - // Check write access - self.check_write_access(group.address_book_id(), user_id).await?; - - self.group_repository.add_contact_to_group(&group_id, &contact_id).await - } - - async fn remove_contact_from_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError> { - let group_id = Self::parse_uuid(&dto.group_id, "ContactGroup")?; - let contact_id = Self::parse_uuid(&dto.contact_id, "Contact")?; - - let group = self.group_repository - .get_group_by_id(&group_id) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; - - // Check write access - self.check_write_access(group.address_book_id(), user_id).await?; - - self.group_repository.remove_contact_from_group(&group_id, &contact_id).await - } - - async fn list_contacts_in_group(&self, group_id: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let group = self.group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found"))?; - - // Check read access - self.check_address_book_access(group.address_book_id(), user_id).await?; - - let contacts = self.group_repository.get_contacts_in_group(&uuid).await?; - Ok(contacts.into_iter().map(ContactDto::from).collect()) - } - - async fn list_groups_for_contact(&self, contact_id: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self.contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check read access - self.check_address_book_access(contact.address_book_id(), user_id).await?; - - let groups = self.group_repository.get_groups_for_contact(&uuid).await?; - Ok(groups.into_iter().map(ContactGroupDto::from).collect()) - } - - async fn get_contact_vcard(&self, contact_id: &str, user_id: &str) -> Result { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self.contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check read access - self.check_address_book_access(contact.address_book_id(), user_id).await?; - - Ok(contact.vcard().to_string()) - } - - async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contacts = self.contact_repository.get_contacts_by_address_book(&uuid).await?; - - Ok(contacts - .into_iter() - .map(|c| (c.id().to_string(), c.vcard().to_string())) - .collect()) - } -} +//! Contact Storage Adapter +//! +//! This adapter implements the `AddressBookUseCase` and `ContactUseCase` application ports +//! using the domain repositories. It bridges the gap between the application layer +//! and the infrastructure layer for CardDAV functionality. + +use async_trait::async_trait; +use std::sync::Arc; +use uuid::Uuid; + +use crate::application::dtos::address_book_dto::{ + AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto, + UpdateAddressBookDto, +}; +use crate::application::dtos::contact_dto::{ + AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, + CreateContactVCardDto, EmailDto, GroupMembershipDto, PhoneDto, UpdateContactDto, + UpdateContactGroupDto, +}; +use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone}; +use crate::domain::repositories::address_book_repository::AddressBookRepository; +use crate::domain::repositories::contact_repository::{ContactGroupRepository, ContactRepository}; + +/// Adapter that implements AddressBookUseCase and ContactUseCase using domain repositories +pub struct ContactStorageAdapter { + address_book_repository: Arc, + contact_repository: Arc, + group_repository: Arc, +} + +impl ContactStorageAdapter { + /// Creates a new ContactStorageAdapter with the given repositories + pub fn new( + address_book_repository: Arc, + contact_repository: Arc, + group_repository: Arc, + ) -> Self { + Self { + address_book_repository, + contact_repository, + group_repository, + } + } + + /// Helper to parse UUID from string + fn parse_uuid(id: &str, entity_name: &'static str) -> Result { + Uuid::parse_str(id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + entity_name, + format!("Invalid {} ID format", entity_name), + ) + }) + } + + /// Helper to check if user has access to an address book + async fn check_address_book_access( + &self, + address_book_id: &Uuid, + user_id: &str, + ) -> Result { + let address_book = self + .address_book_repository + .get_address_book_by_id(address_book_id) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") + })?; + + // Check if user is owner + if address_book.owner_id() == user_id { + return Ok(address_book); + } + + // Check if address book is public + if address_book.is_public() { + return Ok(address_book); + } + + // Check if address book is shared with user + let shares = self + .address_book_repository + .get_address_book_shares(address_book_id) + .await?; + if shares.iter().any(|(shared_user, _)| shared_user == user_id) { + return Ok(address_book); + } + + Err(DomainError::new( + ErrorKind::AccessDenied, + "AddressBook", + "Access denied to address book", + )) + } + + /// Helper to check write access + async fn check_write_access( + &self, + address_book_id: &Uuid, + user_id: &str, + ) -> Result { + let address_book = self + .address_book_repository + .get_address_book_by_id(address_book_id) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") + })?; + + // Owner always has write access + if address_book.owner_id() == user_id { + return Ok(address_book); + } + + // Check shares for write permission + let shares = self + .address_book_repository + .get_address_book_shares(address_book_id) + .await?; + if shares + .iter() + .any(|(shared_user, can_write)| shared_user == user_id && *can_write) + { + return Ok(address_book); + } + + Err(DomainError::new( + ErrorKind::AccessDenied, + "AddressBook", + "Write access denied", + )) + } + + /// Convert EmailDto to domain Email + fn dto_to_email(dto: EmailDto) -> Email { + Email { + email: dto.email, + r#type: dto.r#type, + is_primary: dto.is_primary, + } + } + + /// Convert PhoneDto to domain Phone + fn dto_to_phone(dto: PhoneDto) -> Phone { + Phone { + number: dto.number, + r#type: dto.r#type, + is_primary: dto.is_primary, + } + } + + /// Convert AddressDto to domain Address + fn dto_to_address(dto: AddressDto) -> Address { + Address { + street: dto.street, + city: dto.city, + state: dto.state, + postal_code: dto.postal_code, + country: dto.country, + r#type: dto.r#type, + is_primary: dto.is_primary, + } + } + + /// Generate vCard from contact data + fn generate_vcard(contact: &Contact) -> String { + let mut vcard = String::from("BEGIN:VCARD\nVERSION:3.0\n"); + + if let Some(full_name) = contact.full_name() { + vcard.push_str(&format!("FN:{}\n", full_name)); + } + + if contact.first_name().is_some() || contact.last_name().is_some() { + let last = contact.last_name().unwrap_or(""); + let first = contact.first_name().unwrap_or(""); + vcard.push_str(&format!("N:{};{};;;\n", last, first)); + } + + if let Some(nickname) = contact.nickname() { + vcard.push_str(&format!("NICKNAME:{}\n", nickname)); + } + + for email in contact.email() { + vcard.push_str(&format!( + "EMAIL;TYPE={}:{}\n", + email.r#type.to_uppercase(), + email.email + )); + } + + for phone in contact.phone() { + vcard.push_str(&format!( + "TEL;TYPE={}:{}\n", + phone.r#type.to_uppercase(), + phone.number + )); + } + + if let Some(org) = contact.organization() { + vcard.push_str(&format!("ORG:{}\n", org)); + } + + if let Some(title) = contact.title() { + vcard.push_str(&format!("TITLE:{}\n", title)); + } + + if let Some(notes) = contact.notes() { + vcard.push_str(&format!("NOTE:{}\n", notes)); + } + + vcard.push_str(&format!("UID:{}\n", contact.uid())); + vcard.push_str("END:VCARD\n"); + + vcard + } +} + +#[async_trait] +impl AddressBookUseCase for ContactStorageAdapter { + async fn create_address_book( + &self, + dto: CreateAddressBookDto, + ) -> Result { + let address_book = AddressBook::new( + dto.name, + dto.owner_id, + dto.description, + dto.color, + dto.is_public.unwrap_or(false), + ); + + let created = self + .address_book_repository + .create_address_book(address_book) + .await?; + Ok(AddressBookDto::from(created)) + } + + async fn update_address_book( + &self, + address_book_id: &str, + update: UpdateAddressBookDto, + ) -> Result { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Check write access + let mut address_book = self.check_write_access(&uuid, &update.user_id).await?; + + if let Some(name) = update.name { + address_book.set_name(name); + } + if let Some(description) = update.description { + address_book.set_description(Some(description)); + } + if let Some(color) = update.color { + address_book.set_color(Some(color)); + } + if let Some(is_public) = update.is_public { + address_book.set_is_public(is_public); + } + address_book.set_updated_at(chrono::Utc::now()); + + let updated = self + .address_book_repository + .update_address_book(address_book) + .await?; + Ok(AddressBookDto::from(updated)) + } + + async fn delete_address_book( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result<(), DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Only owner can delete + let address_book = self + .address_book_repository + .get_address_book_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") + })?; + + if address_book.owner_id() != user_id { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "AddressBook", + "Only owner can delete address book", + )); + } + + self.address_book_repository + .delete_address_book(&uuid) + .await + } + + async fn get_address_book( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + let address_book = self.check_address_book_access(&uuid, user_id).await?; + Ok(AddressBookDto::from(address_book)) + } + + async fn list_user_address_books( + &self, + user_id: &str, + ) -> Result, DomainError> { + let owned = self + .address_book_repository + .get_address_books_by_owner(user_id) + .await?; + let shared = self + .address_book_repository + .get_shared_address_books(user_id) + .await?; + + let mut all_books: Vec = owned; + all_books.extend(shared); + + Ok(all_books.into_iter().map(AddressBookDto::from).collect()) + } + + async fn list_public_address_books(&self) -> Result, DomainError> { + let public = self + .address_book_repository + .get_public_address_books() + .await?; + Ok(public.into_iter().map(AddressBookDto::from).collect()) + } + + async fn share_address_book( + &self, + dto: ShareAddressBookDto, + user_id: &str, + ) -> Result<(), DomainError> { + let uuid = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; + + // Only owner can share + let address_book = self + .address_book_repository + .get_address_book_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") + })?; + + if address_book.owner_id() != user_id { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "AddressBook", + "Only owner can share", + )); + } + + self.address_book_repository + .share_address_book(&uuid, &dto.user_id, dto.can_write) + .await + } + + async fn unshare_address_book( + &self, + dto: UnshareAddressBookDto, + user_id: &str, + ) -> Result<(), DomainError> { + let uuid = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; + + // Only owner can unshare + let address_book = self + .address_book_repository + .get_address_book_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") + })?; + + if address_book.owner_id() != user_id { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "AddressBook", + "Only owner can unshare", + )); + } + + self.address_book_repository + .unshare_address_book(&uuid, &dto.user_id) + .await + } + + async fn get_address_book_shares( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Only owner can view shares + let address_book = self + .address_book_repository + .get_address_book_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") + })?; + + if address_book.owner_id() != user_id { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "AddressBook", + "Only owner can view shares", + )); + } + + self.address_book_repository + .get_address_book_shares(&uuid) + .await + } +} + +#[async_trait] +impl ContactUseCase for ContactStorageAdapter { + async fn create_contact(&self, dto: CreateContactDto) -> Result { + let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; + + // Check write access + self.check_write_access(&address_book_id, &dto.user_id) + .await?; + + let now = chrono::Utc::now(); + let mut contact = Contact::from_raw( + Uuid::new_v4(), + address_book_id, + format!("{}@oxicloud", Uuid::new_v4()), + dto.full_name, + dto.first_name, + dto.last_name, + dto.nickname, + dto.email.into_iter().map(Self::dto_to_email).collect(), + dto.phone.into_iter().map(Self::dto_to_phone).collect(), + dto.address.into_iter().map(Self::dto_to_address).collect(), + dto.organization, + dto.title, + dto.notes, + dto.photo_url, + dto.birthday, + dto.anniversary, + String::new(), + Uuid::new_v4().to_string(), + now, + now, + ); + + // Generate vCard + let vcard = Self::generate_vcard(&contact); + contact.set_vcard(vcard); + + let created = self.contact_repository.create_contact(contact).await?; + Ok(ContactDto::from(created)) + } + + async fn create_contact_from_vcard( + &self, + dto: CreateContactVCardDto, + ) -> Result { + let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; + + // Check write access + self.check_write_access(&address_book_id, &dto.user_id) + .await?; + + // 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) + && !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") + && let Some(value) = trimmed.split(':').nth(1) + && !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, + 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, + now, + ); + + let created = self.contact_repository.create_contact(contact).await?; + Ok(ContactDto::from(created)) + } + + async fn update_contact( + &self, + contact_id: &str, + update: UpdateContactDto, + ) -> Result { + let uuid = Self::parse_uuid(contact_id, "Contact")?; + + let mut contact = self + .contact_repository + .get_contact_by_id(&uuid) + .await? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; + + // Check write access to the address book + self.check_write_access(contact.address_book_id(), &update.user_id) + .await?; + + if let Some(full_name) = update.full_name { + contact.set_full_name(Some(full_name)); + } + if let Some(first_name) = update.first_name { + contact.set_first_name(Some(first_name)); + } + if let Some(last_name) = update.last_name { + contact.set_last_name(Some(last_name)); + } + if let Some(nickname) = update.nickname { + contact.set_nickname(Some(nickname)); + } + if let Some(emails) = update.email { + contact.set_email(emails.into_iter().map(Self::dto_to_email).collect()); + } + if let Some(phones) = update.phone { + contact.set_phone(phones.into_iter().map(Self::dto_to_phone).collect()); + } + if let Some(addresses) = update.address { + contact.set_address(addresses.into_iter().map(Self::dto_to_address).collect()); + } + if let Some(organization) = update.organization { + contact.set_organization(Some(organization)); + } + if let Some(title) = update.title { + contact.set_title(Some(title)); + } + if let Some(notes) = update.notes { + contact.set_notes(Some(notes)); + } + if let Some(photo_url) = update.photo_url { + contact.set_photo_url(Some(photo_url)); + } + if let Some(birthday) = update.birthday { + contact.set_birthday(Some(birthday)); + } + if let Some(anniversary) = update.anniversary { + contact.set_anniversary(Some(anniversary)); + } + + contact.set_updated_at(chrono::Utc::now()); + contact.set_etag(Uuid::new_v4().to_string()); + let vcard = Self::generate_vcard(&contact); + contact.set_vcard(vcard); + + let updated = self.contact_repository.update_contact(contact).await?; + Ok(ContactDto::from(updated)) + } + + async fn delete_contact(&self, contact_id: &str, user_id: &str) -> Result<(), DomainError> { + let uuid = Self::parse_uuid(contact_id, "Contact")?; + + let contact = self + .contact_repository + .get_contact_by_id(&uuid) + .await? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; + + // Check write access + self.check_write_access(contact.address_book_id(), user_id) + .await?; + + self.contact_repository.delete_contact(&uuid).await + } + + async fn get_contact( + &self, + contact_id: &str, + user_id: &str, + ) -> Result { + let uuid = Self::parse_uuid(contact_id, "Contact")?; + + let contact = self + .contact_repository + .get_contact_by_id(&uuid) + .await? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; + + // Check read access + self.check_address_book_access(contact.address_book_id(), user_id) + .await?; + + Ok(ContactDto::from(contact)) + } + + async fn list_contacts( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Check read access + self.check_address_book_access(&uuid, user_id).await?; + + let contacts = self + .contact_repository + .get_contacts_by_address_book(&uuid) + .await?; + Ok(contacts.into_iter().map(ContactDto::from).collect()) + } + + async fn search_contacts( + &self, + address_book_id: &str, + query: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Check read access + self.check_address_book_access(&uuid, user_id).await?; + + let contacts = self + .contact_repository + .search_contacts(&uuid, query) + .await?; + Ok(contacts.into_iter().map(ContactDto::from).collect()) + } + + async fn create_group( + &self, + dto: CreateContactGroupDto, + ) -> Result { + let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; + + // Check write access + self.check_write_access(&address_book_id, &dto.user_id) + .await?; + + let group = ContactGroup::new(address_book_id, dto.name); + + let created = self.group_repository.create_group(group).await?; + Ok(ContactGroupDto::from(created)) + } + + async fn update_group( + &self, + group_id: &str, + update: UpdateContactGroupDto, + ) -> Result { + let uuid = Self::parse_uuid(group_id, "ContactGroup")?; + + let mut group = self + .group_repository + .get_group_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") + })?; + + // Check write access + self.check_write_access(group.address_book_id(), &update.user_id) + .await?; + + group.set_name(update.name); + group.set_updated_at(chrono::Utc::now()); + + let updated = self.group_repository.update_group(group).await?; + Ok(ContactGroupDto::from(updated)) + } + + async fn delete_group(&self, group_id: &str, user_id: &str) -> Result<(), DomainError> { + let uuid = Self::parse_uuid(group_id, "ContactGroup")?; + + let group = self + .group_repository + .get_group_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") + })?; + + // Check write access + self.check_write_access(group.address_book_id(), user_id) + .await?; + + self.group_repository.delete_group(&uuid).await + } + + async fn get_group( + &self, + group_id: &str, + user_id: &str, + ) -> Result { + let uuid = Self::parse_uuid(group_id, "ContactGroup")?; + + let group = self + .group_repository + .get_group_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") + })?; + + // Check read access + self.check_address_book_access(group.address_book_id(), user_id) + .await?; + + Ok(ContactGroupDto::from(group)) + } + + async fn list_groups( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Check read access + self.check_address_book_access(&uuid, user_id).await?; + + let groups = self + .group_repository + .get_groups_by_address_book(&uuid) + .await?; + Ok(groups.into_iter().map(ContactGroupDto::from).collect()) + } + + async fn add_contact_to_group( + &self, + dto: GroupMembershipDto, + user_id: &str, + ) -> Result<(), DomainError> { + let group_id = Self::parse_uuid(&dto.group_id, "ContactGroup")?; + let contact_id = Self::parse_uuid(&dto.contact_id, "Contact")?; + + let group = self + .group_repository + .get_group_by_id(&group_id) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") + })?; + + // Check write access + self.check_write_access(group.address_book_id(), user_id) + .await?; + + self.group_repository + .add_contact_to_group(&group_id, &contact_id) + .await + } + + async fn remove_contact_from_group( + &self, + dto: GroupMembershipDto, + user_id: &str, + ) -> Result<(), DomainError> { + let group_id = Self::parse_uuid(&dto.group_id, "ContactGroup")?; + let contact_id = Self::parse_uuid(&dto.contact_id, "Contact")?; + + let group = self + .group_repository + .get_group_by_id(&group_id) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") + })?; + + // Check write access + self.check_write_access(group.address_book_id(), user_id) + .await?; + + self.group_repository + .remove_contact_from_group(&group_id, &contact_id) + .await + } + + async fn list_contacts_in_group( + &self, + group_id: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(group_id, "ContactGroup")?; + + let group = self + .group_repository + .get_group_by_id(&uuid) + .await? + .ok_or_else(|| { + DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") + })?; + + // Check read access + self.check_address_book_access(group.address_book_id(), user_id) + .await?; + + let contacts = self.group_repository.get_contacts_in_group(&uuid).await?; + Ok(contacts.into_iter().map(ContactDto::from).collect()) + } + + async fn list_groups_for_contact( + &self, + contact_id: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(contact_id, "Contact")?; + + let contact = self + .contact_repository + .get_contact_by_id(&uuid) + .await? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; + + // Check read access + self.check_address_book_access(contact.address_book_id(), user_id) + .await?; + + let groups = self.group_repository.get_groups_for_contact(&uuid).await?; + Ok(groups.into_iter().map(ContactGroupDto::from).collect()) + } + + async fn get_contact_vcard( + &self, + contact_id: &str, + user_id: &str, + ) -> Result { + let uuid = Self::parse_uuid(contact_id, "Contact")?; + + let contact = self + .contact_repository + .get_contact_by_id(&uuid) + .await? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; + + // Check read access + self.check_address_book_access(contact.address_book_id(), user_id) + .await?; + + Ok(contact.vcard().to_string()) + } + + async fn get_contacts_as_vcards( + &self, + address_book_id: &str, + user_id: &str, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Check read access + self.check_address_book_access(&uuid, user_id).await?; + + let contacts = self + .contact_repository + .get_contacts_by_address_book(&uuid) + .await?; + + Ok(contacts + .into_iter() + .map(|c| (c.id().to_string(), c.vcard().to_string())) + .collect()) + } +} diff --git a/src/infrastructure/adapters/error_adapters.rs b/src/infrastructure/adapters/error_adapters.rs index 0cde95e1..ad5aa2a2 100644 --- a/src/infrastructure/adapters/error_adapters.rs +++ b/src/infrastructure/adapters/error_adapters.rs @@ -1,125 +1,128 @@ -//! Infrastructure Error Adapters -//! -//! This module contains error conversion adapters for infrastructure-specific errors. -//! These adapters bridge the gap between infrastructure errors (sqlx, serde_json, etc.) -//! and domain errors, keeping the domain layer clean of infrastructure knowledge. -//! -//! Following Clean Architecture principles, these conversions are placed in the -//! infrastructure layer rather than the common/domain layers. - -use crate::domain::errors::{DomainError, ErrorKind}; - -/// Macro to create From implementations for infrastructure errors to DomainError. -/// -/// This macro is intended for use ONLY within the infrastructure layer. -/// The domain layer should not depend on specific infrastructure error types. -/// -/// # Example -/// -/// ```ignore -/// // In infrastructure code: -/// impl_infra_error_to_domain!(serde_json::Error, "Serialization"); -/// impl_infra_error_to_domain!(sqlx::Error, "Database"); -/// ``` -#[macro_export] -macro_rules! impl_infra_error_to_domain { - ($error_type:ty, $entity_type:expr) => { - impl From<$error_type> for $crate::domain::errors::DomainError { - fn from(err: $error_type) -> Self { - $crate::domain::errors::DomainError { - kind: $crate::domain::errors::ErrorKind::InternalError, - entity_type: $entity_type, - entity_id: None, - message: format!("{}", err), - source: Some(Box::new(err)), - } - } - } - }; -} - -// Note: We intentionally DO NOT create global From implementations for sqlx::Error -// or serde_json::Error here. Each repository/service should handle its own error -// conversions with proper context. This prevents the domain from depending on -// infrastructure error types. - -/// Helper trait for converting infrastructure errors to DomainError with context. -/// -/// This trait provides a more explicit way to convert infrastructure errors -/// to domain errors, requiring the caller to provide context about the entity -/// being operated on. -pub trait IntoDomainError { - /// Convert the error to a DomainError with the given entity type context. - fn into_domain_error(self, entity_type: &'static str) -> DomainError; -} - -impl IntoDomainError for std::io::Error { - fn into_domain_error(self, entity_type: &'static str) -> DomainError { - DomainError::new( - ErrorKind::InternalError, - entity_type, - format!("IO error: {}", self), - ).with_source(self) - } -} - -impl IntoDomainError for serde_json::Error { - fn into_domain_error(self, entity_type: &'static str) -> DomainError { - DomainError::new( - ErrorKind::InternalError, - entity_type, - format!("Serialization error: {}", self), - ).with_source(self) - } -} - -impl IntoDomainError for sqlx::Error { - fn into_domain_error(self, entity_type: &'static str) -> DomainError { - match &self { - sqlx::Error::RowNotFound => { - DomainError::not_found(entity_type, "Record not found") - } - sqlx::Error::Database(db_err) => { - // Handle specific PostgreSQL error codes - if db_err.code().is_some_and(|c| c == "23505") { - DomainError::already_exists(entity_type, "Record already exists") - } else { - DomainError::new( - ErrorKind::DatabaseError, - entity_type, - format!("Database error: {}", db_err), - ).with_source(self) - } - } - _ => DomainError::new( - ErrorKind::InternalError, - entity_type, - format!("Database error: {}", self), - ).with_source(self) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_io_error_conversion() { - let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); - let domain_error = io_error.into_domain_error("File"); - - assert_eq!(domain_error.entity_type, "File"); - assert!(domain_error.message.contains("IO error")); - } - - #[test] - fn test_serde_json_error_conversion() { - let json_str = "{ invalid json }"; - let serde_error: serde_json::Error = serde_json::from_str::(json_str).unwrap_err(); - let domain_error = serde_error.into_domain_error("Config"); - - assert_eq!(domain_error.entity_type, "Config"); - assert!(domain_error.message.contains("Serialization error")); - } -} +//! Infrastructure Error Adapters +//! +//! This module contains error conversion adapters for infrastructure-specific errors. +//! These adapters bridge the gap between infrastructure errors (sqlx, serde_json, etc.) +//! and domain errors, keeping the domain layer clean of infrastructure knowledge. +//! +//! Following Clean Architecture principles, these conversions are placed in the +//! infrastructure layer rather than the common/domain layers. + +use crate::domain::errors::{DomainError, ErrorKind}; + +/// Macro to create From implementations for infrastructure errors to DomainError. +/// +/// This macro is intended for use ONLY within the infrastructure layer. +/// The domain layer should not depend on specific infrastructure error types. +/// +/// # Example +/// +/// ```ignore +/// // In infrastructure code: +/// impl_infra_error_to_domain!(serde_json::Error, "Serialization"); +/// impl_infra_error_to_domain!(sqlx::Error, "Database"); +/// ``` +#[macro_export] +macro_rules! impl_infra_error_to_domain { + ($error_type:ty, $entity_type:expr) => { + impl From<$error_type> for $crate::domain::errors::DomainError { + fn from(err: $error_type) -> Self { + $crate::domain::errors::DomainError { + kind: $crate::domain::errors::ErrorKind::InternalError, + entity_type: $entity_type, + entity_id: None, + message: format!("{}", err), + source: Some(Box::new(err)), + } + } + } + }; +} + +// Note: We intentionally DO NOT create global From implementations for sqlx::Error +// or serde_json::Error here. Each repository/service should handle its own error +// conversions with proper context. This prevents the domain from depending on +// infrastructure error types. + +/// Helper trait for converting infrastructure errors to DomainError with context. +/// +/// This trait provides a more explicit way to convert infrastructure errors +/// to domain errors, requiring the caller to provide context about the entity +/// being operated on. +pub trait IntoDomainError { + /// Convert the error to a DomainError with the given entity type context. + fn into_domain_error(self, entity_type: &'static str) -> DomainError; +} + +impl IntoDomainError for std::io::Error { + fn into_domain_error(self, entity_type: &'static str) -> DomainError { + DomainError::new( + ErrorKind::InternalError, + entity_type, + format!("IO error: {}", self), + ) + .with_source(self) + } +} + +impl IntoDomainError for serde_json::Error { + fn into_domain_error(self, entity_type: &'static str) -> DomainError { + DomainError::new( + ErrorKind::InternalError, + entity_type, + format!("Serialization error: {}", self), + ) + .with_source(self) + } +} + +impl IntoDomainError for sqlx::Error { + fn into_domain_error(self, entity_type: &'static str) -> DomainError { + match &self { + sqlx::Error::RowNotFound => DomainError::not_found(entity_type, "Record not found"), + sqlx::Error::Database(db_err) => { + // Handle specific PostgreSQL error codes + if db_err.code().is_some_and(|c| c == "23505") { + DomainError::already_exists(entity_type, "Record already exists") + } else { + DomainError::new( + ErrorKind::DatabaseError, + entity_type, + format!("Database error: {}", db_err), + ) + .with_source(self) + } + } + _ => DomainError::new( + ErrorKind::InternalError, + entity_type, + format!("Database error: {}", self), + ) + .with_source(self), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_io_error_conversion() { + let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let domain_error = io_error.into_domain_error("File"); + + assert_eq!(domain_error.entity_type, "File"); + assert!(domain_error.message.contains("IO error")); + } + + #[test] + fn test_serde_json_error_conversion() { + let json_str = "{ invalid json }"; + let serde_error: serde_json::Error = + serde_json::from_str::(json_str).unwrap_err(); + let domain_error = serde_error.into_domain_error("Config"); + + assert_eq!(domain_error.entity_type, "Config"); + assert!(domain_error.message.contains("Serialization error")); + } +} diff --git a/src/infrastructure/adapters/mod.rs b/src/infrastructure/adapters/mod.rs index fe06fd01..70b0b1b1 100644 --- a/src/infrastructure/adapters/mod.rs +++ b/src/infrastructure/adapters/mod.rs @@ -1,16 +1,16 @@ -//! Infrastructure Adapters -//! -//! This module contains adapters that bridge the gap between domain repositories -//! and application ports. These adapters implement the application layer ports -//! using the infrastructure layer repositories. -//! -//! It also includes error adapters for converting infrastructure-specific errors -//! to domain errors, following Clean Architecture principles. - -pub mod calendar_storage_adapter; -pub mod contact_storage_adapter; -pub mod error_adapters; - -pub use calendar_storage_adapter::CalendarStorageAdapter; -pub use contact_storage_adapter::ContactStorageAdapter; -pub use error_adapters::IntoDomainError; +//! Infrastructure Adapters +//! +//! This module contains adapters that bridge the gap between domain repositories +//! and application ports. These adapters implement the application layer ports +//! using the infrastructure layer repositories. +//! +//! It also includes error adapters for converting infrastructure-specific errors +//! to domain errors, following Clean Architecture principles. + +pub mod calendar_storage_adapter; +pub mod contact_storage_adapter; +pub mod error_adapters; + +pub use calendar_storage_adapter::CalendarStorageAdapter; +pub use contact_storage_adapter::ContactStorageAdapter; +pub use error_adapters::IntoDomainError; diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index 79fd2f4e..2f3bb0db 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -1,21 +1,21 @@ -use std::sync::Arc; use anyhow::Result; use sqlx::PgPool; +use std::sync::Arc; use crate::application::ports::auth_ports::TokenServicePort; use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::folder_service::FolderService; -use crate::infrastructure::repositories::{UserPgRepository, SessionPgRepository}; -use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; -use crate::infrastructure::services::jwt_service::JwtTokenService; -use crate::infrastructure::services::oidc_service::OidcService; use crate::common::config::AppConfig; use crate::common::di::AuthServices; +use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository}; +use crate::infrastructure::services::jwt_service::JwtTokenService; +use crate::infrastructure::services::oidc_service::OidcService; +use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; pub async fn create_auth_services( - config: &AppConfig, + config: &AppConfig, pool: Arc, - folder_service: Option> + folder_service: Option>, ) -> Result { // Create JWT token service (TokenServicePort implementation) let token_service: Arc = Arc::new(JwtTokenService::new( @@ -23,14 +23,14 @@ pub async fn create_auth_services( config.auth.access_token_expiry_secs, config.auth.refresh_token_expiry_secs, )); - + // Create password hashing service let password_hasher = Arc::new(Argon2PasswordHasher::new()); - + // Create PostgreSQL repositories let user_repository = Arc::new(UserPgRepository::new(pool.clone())); let session_repository = Arc::new(SessionPgRepository::new(pool.clone())); - + // Create authentication application service let mut auth_app_service = AuthApplicationService::new( user_repository, @@ -39,7 +39,7 @@ pub async fn create_auth_services( token_service.clone(), config.storage_path.clone(), ); - + // Configure folder service if available if let Some(folder_svc) = folder_service { auth_app_service = auth_app_service.with_folder_service(folder_svc); @@ -47,9 +47,12 @@ pub async fn create_auth_services( // Configure OIDC service if enabled if config.oidc.enabled { - tracing::info!("Initializing OIDC service (provider: {}, issuer: {})", - config.oidc.provider_name, config.oidc.issuer_url); - + tracing::info!( + "Initializing OIDC service (provider: {}, issuer: {})", + config.oidc.provider_name, + config.oidc.issuer_url + ); + let oidc_service = Arc::new(OidcService::new(config.oidc.clone())); auth_app_service = auth_app_service.with_oidc(oidc_service, config.oidc.clone()); @@ -57,12 +60,12 @@ pub async fn create_auth_services( tracing::warn!("Password login is DISABLED — only OIDC authentication is allowed"); } } - + // Package service in Arc let auth_application_service = Arc::new(auth_app_service); - + Ok(AuthServices { token_service, auth_application_service, }) -} \ No newline at end of file +} diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs index 69a8deb2..4c2b3bfb 100644 --- a/src/infrastructure/db.rs +++ b/src/infrastructure/db.rs @@ -1,19 +1,28 @@ -use sqlx::{postgres::PgPoolOptions, PgPool, Row}; -use anyhow::Result; -use std::time::Duration; use crate::common::config::AppConfig; +use anyhow::Result; +use sqlx::{PgPool, Row, postgres::PgPoolOptions}; +use std::time::Duration; pub async fn create_database_pool(config: &AppConfig) -> Result { - tracing::info!("Initializing PostgreSQL connection with URL: {}", - config.database.connection_string.replace("postgres://", "postgres://[user]:[pass]@")); - + tracing::info!( + "Initializing PostgreSQL connection with URL: {}", + config + .database + .connection_string + .replace("postgres://", "postgres://[user]:[pass]@") + ); + let mut attempt = 0; const MAX_ATTEMPTS: usize = 5; - + while attempt < MAX_ATTEMPTS { attempt += 1; - tracing::info!("PostgreSQL connection attempt #{}/{}", attempt, MAX_ATTEMPTS); - + tracing::info!( + "PostgreSQL connection attempt #{}/{}", + attempt, + MAX_ATTEMPTS + ); + match PgPoolOptions::new() .max_connections(config.database.max_connections) .min_connections(config.database.min_connections) @@ -21,60 +30,76 @@ pub async fn create_database_pool(config: &AppConfig) -> Result { .idle_timeout(Duration::from_secs(config.database.idle_timeout_secs)) .max_lifetime(Duration::from_secs(config.database.max_lifetime_secs)) .connect(&config.database.connection_string) - .await { - Ok(pool) => { - match sqlx::query("SELECT 1").execute(&pool).await { - Ok(_) => { - tracing::info!("PostgreSQL connection established successfully"); - + .await + { + Ok(pool) => { + match sqlx::query("SELECT 1").execute(&pool).await { + Ok(_) => { + tracing::info!("PostgreSQL connection established successfully"); + + if !tables_exist(&pool).await { + tracing::warn!("Database tables do not exist. Auto-applying schema..."); + if let Err(e) = apply_schema(&pool).await { + return Err(anyhow::anyhow!( + "Database schema could not be applied: {}. \ + Run manually: psql -f db/schema.sql", + e + )); + } + + // Verify tables were actually created if !tables_exist(&pool).await { - tracing::warn!("Database tables do not exist. Auto-applying schema..."); - if let Err(e) = apply_schema(&pool).await { - return Err(anyhow::anyhow!( - "Database schema could not be applied: {}. \ - Run manually: psql -f db/schema.sql", e - )); - } - - // Verify tables were actually created - if !tables_exist(&pool).await { - return Err(anyhow::anyhow!( - "Database schema was applied but tables still missing. \ + return Err(anyhow::anyhow!( + "Database schema was applied but tables still missing. \ Check db/schema.sql for errors." - )); - } - tracing::info!("Database schema applied and verified successfully"); - } - - return Ok(pool); - }, - Err(e) => { - tracing::error!("Error verifying connection: {}", e); - if attempt >= MAX_ATTEMPTS { - return Err(anyhow::anyhow!("Error verifying PostgreSQL connection: {}", e)); + )); } + tracing::info!("Database schema applied and verified successfully"); + } + + return Ok(pool); + } + Err(e) => { + tracing::error!("Error verifying connection: {}", e); + if attempt >= MAX_ATTEMPTS { + return Err(anyhow::anyhow!( + "Error verifying PostgreSQL connection: {}", + e + )); } } - }, - Err(e) => { - tracing::error!("Error connecting to PostgreSQL (attempt {}/{}): {}", attempt, MAX_ATTEMPTS, e); - if attempt >= MAX_ATTEMPTS { - return Err(anyhow::anyhow!("Error in PostgreSQL connection: {}", e)); - } - tokio::time::sleep(Duration::from_secs(2)).await; } } + Err(e) => { + tracing::error!( + "Error connecting to PostgreSQL (attempt {}/{}): {}", + attempt, + MAX_ATTEMPTS, + e + ); + if attempt >= MAX_ATTEMPTS { + return Err(anyhow::anyhow!("Error in PostgreSQL connection: {}", e)); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + } } - - Err(anyhow::anyhow!("Could not establish PostgreSQL connection after {} attempts", MAX_ATTEMPTS)) + + Err(anyhow::anyhow!( + "Could not establish PostgreSQL connection after {} attempts", + MAX_ATTEMPTS + )) } /// Check whether the core auth tables exist in the database. async fn tables_exist(pool: &PgPool) -> bool { - sqlx::query("SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users')") - .fetch_one(pool) - .await.map(|row| row.get::(0)) - .unwrap_or(false) + sqlx::query( + "SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users')", + ) + .fetch_one(pool) + .await + .map(|row| row.get::(0)) + .unwrap_or(false) } /// Apply the embedded schema.sql to the database. @@ -82,15 +107,18 @@ async fn tables_exist(pool: &PgPool) -> bool { /// to splitting the SQL into individual statements and executing them one by one. async fn apply_schema(pool: &PgPool) -> Result<()> { let schema_sql = include_str!("../../db/schema.sql"); - + // Attempt 1: raw_sql sends the entire script via the simple query protocol match sqlx::raw_sql(schema_sql).execute(pool).await { Ok(_) => return Ok(()), Err(e) => { - tracing::warn!("raw_sql failed ({}), falling back to statement-by-statement execution", e); + tracing::warn!( + "raw_sql failed ({}), falling back to statement-by-statement execution", + e + ); } } - + // Attempt 2: split into individual statements respecting dollar-quoting let statements = split_sql_statements(schema_sql); for (i, stmt) in statements.iter().enumerate() { @@ -99,12 +127,21 @@ async fn apply_schema(pool: &PgPool) -> Result<()> { continue; } if let Err(e) = sqlx::raw_sql(trimmed).execute(pool).await { - let preview = if trimmed.len() > 200 { &trimmed[..200] } else { trimmed }; - tracing::error!("Schema statement {} failed: {}\n--- SQL ---\n{}\n-----------", i + 1, e, preview); + let preview = if trimmed.len() > 200 { + &trimmed[..200] + } else { + trimmed + }; + tracing::error!( + "Schema statement {} failed: {}\n--- SQL ---\n{}\n-----------", + i + 1, + e, + preview + ); return Err(anyhow::anyhow!("Schema statement {} failed: {}", i + 1, e)); } } - + Ok(()) } @@ -119,7 +156,7 @@ fn split_sql_statements(sql: &str) -> Vec { let chars: Vec = sql.chars().collect(); let len = chars.len(); let mut i = 0; - + while i < len { // Line comment if i + 1 < len && chars[i] == '-' && chars[i + 1] == '-' { @@ -129,7 +166,7 @@ fn split_sql_statements(sql: &str) -> Vec { } continue; } - + // Block comment if i + 1 < len && chars[i] == '/' && chars[i + 1] == '*' { current.push(chars[i]); @@ -146,7 +183,7 @@ fn split_sql_statements(sql: &str) -> Vec { } continue; } - + // Single-quoted string if chars[i] == '\'' { current.push(chars[i]); @@ -167,7 +204,7 @@ fn split_sql_statements(sql: &str) -> Vec { } continue; } - + // Dollar-quoted string ($tag$...$tag$ or $$...$$) if chars[i] == '$' { let _start = i; @@ -203,7 +240,7 @@ fn split_sql_statements(sql: &str) -> Vec { } continue; } - + // Statement separator if chars[i] == ';' { current.push(';'); @@ -215,16 +252,16 @@ fn split_sql_statements(sql: &str) -> Vec { i += 1; continue; } - + current.push(chars[i]); i += 1; } - + // Trailing statement without semicolon let trimmed = current.trim().to_string(); if !trimmed.is_empty() && trimmed != ";" { statements.push(trimmed); } - + statements -} \ No newline at end of file +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 478a3be2..c6383e4d 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -3,4 +3,3 @@ pub mod auth_factory; pub mod db; pub mod repositories; pub mod services; - diff --git a/src/infrastructure/repositories/composite_file_repository.rs b/src/infrastructure/repositories/composite_file_repository.rs index 7d3cbdeb..bd1ccd82 100644 --- a/src/infrastructure/repositories/composite_file_repository.rs +++ b/src/infrastructure/repositories/composite_file_repository.rs @@ -86,7 +86,9 @@ impl FileWritePort for CompositeFileRepository { content_type: String, content: Vec, ) -> Result { - self.write.save_file(name, folder_id, content_type, content).await + self.write + .save_file(name, folder_id, content_type, content) + .await } async fn save_file_from_stream( @@ -96,7 +98,9 @@ impl FileWritePort for CompositeFileRepository { content_type: String, stream: std::pin::Pin> + Send>>, ) -> Result { - self.write.save_file_from_stream(name, folder_id, content_type, stream).await + self.write + .save_file_from_stream(name, folder_id, content_type, stream) + .await } async fn move_file( @@ -107,11 +111,7 @@ impl FileWritePort for CompositeFileRepository { self.write.move_file(file_id, target_folder_id).await } - async fn rename_file( - &self, - file_id: &str, - new_name: &str, - ) -> Result { + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { self.write.rename_file(file_id, new_name).await } @@ -119,7 +119,11 @@ impl FileWritePort for CompositeFileRepository { self.write.delete_file(id).await } - async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError> { + async fn update_file_content( + &self, + file_id: &str, + content: Vec, + ) -> Result<(), DomainError> { self.write.update_file_content(file_id, content).await } @@ -130,14 +134,20 @@ impl FileWritePort for CompositeFileRepository { content_type: String, size: u64, ) -> Result<(File, PathBuf), DomainError> { - self.write.register_file_deferred(name, folder_id, content_type, size).await + self.write + .register_file_deferred(name, folder_id, content_type, size) + .await } async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { self.write.move_to_trash(file_id).await } - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError> { + async fn restore_from_trash( + &self, + file_id: &str, + original_path: &str, + ) -> Result<(), DomainError> { self.write.restore_from_trash(file_id, original_path).await } diff --git a/src/infrastructure/repositories/file_fs_read_repository.rs b/src/infrastructure/repositories/file_fs_read_repository.rs index bf00031d..ee018f73 100644 --- a/src/infrastructure/repositories/file_fs_read_repository.rs +++ b/src/infrastructure/repositories/file_fs_read_repository.rs @@ -2,24 +2,26 @@ use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; -use tokio::{fs, time}; -use tokio::fs::File as TokioFile; -use tokio_util::codec::{BytesCodec, FramedRead}; -use futures::{Stream, StreamExt}; use bytes::Bytes; -use tokio::task; +use futures::{Stream, StreamExt}; use mime_guess::from_path; +use tokio::fs::File as TokioFile; +use tokio::task; +use tokio::{fs, time}; +use tokio_util::codec::{BytesCodec, FramedRead}; -use crate::domain::entities::file::File; -use crate::application::ports::storage_ports::FileReadPort; -use crate::common::errors::DomainError; -use crate::infrastructure::repositories::repository_errors::{FileRepositoryResult, FileRepositoryError}; -use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; use crate::application::ports::cache_ports::MetadataCachePort; +use crate::application::ports::storage_ports::FileReadPort; use crate::application::services::storage_mediator::StorageMediator; -use crate::infrastructure::services::path_service::PathService; -use crate::domain::services::path_service::StoragePath; use crate::common::config::AppConfig; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::services::path_service::StoragePath; +use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; +use crate::infrastructure::repositories::repository_errors::{ + FileRepositoryError, FileRepositoryResult, +}; +use crate::infrastructure::services::path_service::PathService; /// Repository implementation for file **read** operations. /// @@ -63,12 +65,13 @@ impl FileFsReadRepository { Self { root_path: PathBuf::from("./storage"), storage_mediator: Arc::new( - crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub(), + crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub( + ), ), id_mapping_service: Arc::new(crate::common::stubs::StubIdMappingPort), path_service: Arc::new(PathService::new(PathBuf::from("./storage"))), metadata_cache: Arc::new( - crate::infrastructure::services::file_metadata_cache::FileMetadataCache::default() + crate::infrastructure::services::file_metadata_cache::FileMetadataCache::default(), ) as Arc, config: AppConfig::default(), parallel_processor: None, @@ -81,36 +84,61 @@ impl FileFsReadRepository { self.path_service.resolve_path(storage_path) } - async fn get_file_metadata_raw(&self, abs_path: &PathBuf) -> FileRepositoryResult<(u64, u64, u64)> { + async fn get_file_metadata_raw( + &self, + abs_path: &PathBuf, + ) -> FileRepositoryResult<(u64, u64, u64)> { // Cache first if let Some(cached) = self.metadata_cache.get_metadata(abs_path).await - && let (Some(s), Some(c), Some(m)) = (cached.size, cached.created_at, cached.modified_at) { - return Ok((s, c, m)); - } + && let (Some(s), Some(c), Some(m)) = + (cached.size, cached.created_at, cached.modified_at) + { + return Ok((s, c, m)); + } let metadata = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(abs_path)) .await - .map_err(|_| FileRepositoryError::StorageError(format!("Timeout metadata: {}", abs_path.display())))? + .map_err(|_| { + FileRepositoryError::StorageError(format!( + "Timeout metadata: {}", + abs_path.display() + )) + })? .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; let size = metadata.len(); - let created_at = metadata.created() - .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + let created_at = metadata + .created() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .unwrap_or(0); - let modified_at = metadata.modified() - .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + let modified_at = metadata + .modified() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .unwrap_or(0); let _ = self.metadata_cache.refresh_metadata(abs_path).await; Ok((size, created_at, modified_at)) } async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult { - let storage_path = self.id_mapping_service.get_path_by_id(id).await + let storage_path = self + .id_mapping_service + .get_path_by_id(id) + .await .map_err(|e| FileRepositoryError::Other(e.to_string()))?; let abs_path = self.resolve_storage_path(&storage_path); if !abs_path.exists() || !abs_path.is_file() { - return Err(FileRepositoryError::NotFound( - format!("File {} not found at {}", id, storage_path.to_string()), - )); + return Err(FileRepositoryError::NotFound(format!( + "File {} not found at {}", + id, + storage_path.to_string() + ))); } let (size, created_at, modified_at) = self.get_file_metadata_raw(&abs_path).await?; @@ -120,8 +148,14 @@ impl FileFsReadRepository { let mime_type = from_path(&abs_path).first_or_octet_stream().to_string(); File::with_timestamps( - id.to_string(), name, storage_path, size, mime_type, None, - created_at, modified_at, + id.to_string(), + name, + storage_path, + size, + mime_type, + None, + created_at, + modified_at, ) .map_err(|e| FileRepositoryError::Other(e.to_string())) } @@ -153,18 +187,14 @@ impl FileReadPort for FileFsReadRepository { async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { let folder_storage_path = match folder_id { - Some(id) => { - match self.storage_mediator.get_folder_path(id).await { - Ok(path) => { - let lossy = path.to_string_lossy().to_string(); - let folder_name = path.file_name() - .and_then(|f| f.to_str()) - .unwrap_or(&lossy); - StoragePath::from_string(folder_name) - } - Err(_) => return Ok(Vec::new()), + Some(id) => match self.storage_mediator.get_folder_path(id).await { + Ok(path) => { + let lossy = path.to_string_lossy().to_string(); + let folder_name = path.file_name().and_then(|f| f.to_str()).unwrap_or(&lossy); + StoragePath::from_string(folder_name) } - } + Err(_) => return Ok(Vec::new()), + }, None => StoragePath::root(), }; @@ -174,16 +204,24 @@ impl FileReadPort for FileFsReadRepository { } let mut files_result = Vec::new(); - let mut entries = fs::read_dir(&abs_folder_path).await + let mut entries = fs::read_dir(&abs_folder_path) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - while let Some(entry) = entries.next_entry().await + while let Some(entry) = entries + .next_entry() + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))? { let path = entry.path(); - if !path.is_file() { continue; } + if !path.is_file() { + continue; + } let file_name = entry.file_name().to_string_lossy().to_string(); - if file_name.starts_with('.') || file_name == "folder_ids.json" || file_name == "file_ids.json" { + if file_name.starts_with('.') + || file_name == "folder_ids.json" + || file_name == "file_ids.json" + { continue; } let metadata = match fs::metadata(&path).await { @@ -191,20 +229,43 @@ impl FileReadPort for FileFsReadRepository { Err(_) => continue, }; let file_storage_path = folder_storage_path.join(&file_name); - let id = match self.id_mapping_service.get_or_create_id(&file_storage_path).await { + let id = match self + .id_mapping_service + .get_or_create_id(&file_storage_path) + .await + { Ok(id) => id, Err(_) => continue, }; let size = metadata.len(); - let created_at = metadata.created() - .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + let created_at = metadata + .created() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .unwrap_or(0); - let modified_at = metadata.modified() - .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + let modified_at = metadata + .modified() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .unwrap_or(0); let mime_type = from_path(&path).first_or_octet_stream().to_string(); - match File::with_timestamps(id, file_name, file_storage_path, size, mime_type, folder_id.map(String::from), created_at, modified_at) { + match File::with_timestamps( + id, + file_name, + file_storage_path, + size, + mime_type, + folder_id.map(String::from), + created_at, + modified_at, + ) { Ok(file) => files_result.push(file), Err(_) => continue, } @@ -216,23 +277,39 @@ impl FileReadPort for FileFsReadRepository { } async fn get_file_content(&self, id: &str) -> Result, DomainError> { - let file = self.get_file_by_id(id).await + let file = self + .get_file_by_id(id) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let abs_path = self.resolve_storage_path(file.storage_path()); let metadata = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(&abs_path)) .await - .map_err(|_| DomainError::internal_error("File", format!("Timeout metadata: {}", abs_path.display())))? + .map_err(|_| { + DomainError::internal_error( + "File", + format!("Timeout metadata: {}", abs_path.display()), + ) + })? .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let file_size = metadata.len(); if !self.config.resources.can_load_in_memory(file_size) { - return Err(DomainError::internal_error("File", - format!("File too large for memory: {} MB", file_size / (1024 * 1024)))); + return Err(DomainError::internal_error( + "File", + format!( + "File too large for memory: {} MB", + file_size / (1024 * 1024) + ), + )); } // Parallel read for very large files - if self.config.resources.needs_parallel_processing(file_size, &self.config.concurrency) { + if self + .config + .resources + .needs_parallel_processing(file_size, &self.config.concurrency) + { let content = if let Some(processor) = &self.parallel_processor { processor.read_file_parallel(&abs_path).await } else { @@ -247,13 +324,14 @@ impl FileReadPort for FileFsReadRepository { let abs_clone = abs_path.clone(); let chunk_size = self.config.resources.chunk_size_bytes; let content = task::spawn_blocking(move || -> std::io::Result> { - use std::io::{Read, BufReader}; + use std::io::{BufReader, Read}; let file = std::fs::File::open(&abs_clone)?; let mut reader = BufReader::with_capacity(chunk_size, file); let mut buf = Vec::with_capacity(file_size as usize); reader.read_to_end(&mut buf)?; Ok(buf) - }).await + }) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))? .map_err(|e| DomainError::internal_error("File", e.to_string()))?; return Ok(content); @@ -262,7 +340,12 @@ impl FileReadPort for FileFsReadRepository { // Small files — async read time::timeout(self.config.timeouts.file_timeout(), fs::read(&abs_path)) .await - .map_err(|_| DomainError::internal_error("File", format!("Timeout reading: {}", abs_path.display())))? + .map_err(|_| { + DomainError::internal_error( + "File", + format!("Timeout reading: {}", abs_path.display()), + ) + })? .map_err(|e| DomainError::internal_error("File", e.to_string())) } @@ -270,7 +353,9 @@ impl FileReadPort for FileFsReadRepository { &self, id: &str, ) -> Result> + Send>, DomainError> { - let file = self.get_file_by_id(id).await + let file = self + .get_file_by_id(id) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let abs_path = self.resolve_storage_path(file.storage_path()); @@ -281,15 +366,22 @@ impl FileReadPort for FileFsReadRepository { let file_size = metadata.len(); let is_large = self.config.resources.is_large_file(file_size); - let fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::open(&abs_path)) - .await - .map_err(|_| DomainError::internal_error("File", "Timeout opening file"))? - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let fh = time::timeout( + self.config.timeouts.file_timeout(), + TokioFile::open(&abs_path), + ) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout opening file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - let chunk_size = if is_large { self.config.resources.chunk_size_bytes } else { 4096 }; + let chunk_size = if is_large { + self.config.resources.chunk_size_bytes + } else { + 4096 + }; let codec = BytesCodec::new(); - let stream = FramedRead::with_capacity(fh, codec, chunk_size) - .map(|r| r.map(|bm| bm.freeze())); + let stream = + FramedRead::with_capacity(fh, codec, chunk_size).map(|r| r.map(|bm| bm.freeze())); Ok(Box::new(stream)) } @@ -301,7 +393,9 @@ impl FileReadPort for FileFsReadRepository { ) -> Result> + Send>, DomainError> { use tokio::io::AsyncSeekExt; - let file = self.get_file_by_id(id).await + let file = self + .get_file_by_id(id) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let abs_path = self.resolve_storage_path(file.storage_path()); @@ -311,31 +405,43 @@ impl FileReadPort for FileFsReadRepository { .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let file_size = metadata.len(); if start >= file_size { - return Err(DomainError::internal_error("File", - format!("Range start {} beyond file size {}", start, file_size))); + return Err(DomainError::internal_error( + "File", + format!("Range start {} beyond file size {}", start, file_size), + )); } let actual_end = end.map(|e| e.min(file_size - 1)).unwrap_or(file_size - 1); let range_length = actual_end - start + 1; - let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::open(&abs_path)) + let mut fh = time::timeout( + self.config.timeouts.file_timeout(), + TokioFile::open(&abs_path), + ) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout opening file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.seek(std::io::SeekFrom::Start(start)) .await - .map_err(|_| DomainError::internal_error("File", "Timeout opening file"))? - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - fh.seek(std::io::SeekFrom::Start(start)).await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - let chunk_size = if range_length > 1024 * 1024 { self.config.resources.chunk_size_bytes } else { 8192 }; + let chunk_size = if range_length > 1024 * 1024 { + self.config.resources.chunk_size_bytes + } else { + 8192 + }; use tokio::io::AsyncReadExt; let limited = fh.take(range_length); let codec = BytesCodec::new(); - let stream = FramedRead::with_capacity(limited, codec, chunk_size) - .map(|r| r.map(|bm| bm.freeze())); + let stream = + FramedRead::with_capacity(limited, codec, chunk_size).map(|r| r.map(|bm| bm.freeze())); Ok(Box::new(stream)) } async fn get_file_mmap(&self, id: &str) -> Result { use memmap2::Mmap; - let file = self.get_file_by_id(id).await + let file = self + .get_file_by_id(id) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let abs_path = self.resolve_storage_path(file.storage_path()); let path_clone = abs_path.clone(); @@ -346,7 +452,8 @@ impl FileReadPort for FileFsReadRepository { let mmap = unsafe { Mmap::map(&fh) } .map_err(|e| DomainError::internal_error("File", e.to_string()))?; Ok(Bytes::copy_from_slice(&mmap[..])) - }).await + }) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))? } @@ -363,4 +470,4 @@ impl FileReadPort for FileFsReadRepository { _ => Ok("root".to_string()), } } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/file_fs_write_repository.rs b/src/infrastructure/repositories/file_fs_write_repository.rs index 127f2475..fbf66d19 100644 --- a/src/infrastructure/repositories/file_fs_write_repository.rs +++ b/src/infrastructure/repositories/file_fs_write_repository.rs @@ -1,25 +1,27 @@ +use async_trait::async_trait; +use bytes::Bytes; +use futures::{Stream, StreamExt}; +use mime_guess::from_path; use std::path::PathBuf; use std::sync::Arc; -use async_trait::async_trait; -use tokio::{fs, time}; use tokio::fs::File as TokioFile; use tokio::io::AsyncWriteExt; -use futures::{Stream, StreamExt}; -use bytes::Bytes; -use mime_guess::from_path; use tokio::task; +use tokio::{fs, time}; -use crate::domain::entities::file::File; -use crate::application::ports::storage_ports::FileWritePort; -use crate::common::errors::DomainError; -use crate::infrastructure::repositories::repository_errors::{FileRepositoryResult, FileRepositoryError}; -use crate::infrastructure::services::file_system_utils::FileSystemUtils; use crate::application::ports::cache_ports::MetadataCachePort; -use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; +use crate::application::ports::storage_ports::FileWritePort; use crate::application::services::storage_mediator::StorageMediator; -use crate::infrastructure::services::path_service::PathService; -use crate::domain::services::path_service::StoragePath; use crate::common::config::AppConfig; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::services::path_service::StoragePath; +use crate::infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; +use crate::infrastructure::repositories::repository_errors::{ + FileRepositoryError, FileRepositoryResult, +}; +use crate::infrastructure::services::file_system_utils::FileSystemUtils; +use crate::infrastructure::services::path_service::PathService; /// Repository implementation for file **write** operations. /// @@ -47,7 +49,15 @@ impl FileFsWriteRepository { config: AppConfig, parallel_processor: Option>, ) -> Self { - Self { root_path, storage_mediator, id_mapping_service, path_service, metadata_cache, config, parallel_processor } + Self { + root_path, + storage_mediator, + id_mapping_service, + path_service, + metadata_cache, + config, + parallel_processor, + } } /// Stub for testing (does not perform real I/O). @@ -55,12 +65,13 @@ impl FileFsWriteRepository { Self { root_path: PathBuf::from("./storage"), storage_mediator: Arc::new( - crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub(), + crate::application::services::storage_mediator::FileSystemStorageMediator::new_stub( + ), ), id_mapping_service: Arc::new(crate::common::stubs::StubIdMappingPort), path_service: Arc::new(PathService::new(PathBuf::from("./storage"))), metadata_cache: Arc::new( - crate::infrastructure::services::file_metadata_cache::FileMetadataCache::default() + crate::infrastructure::services::file_metadata_cache::FileMetadataCache::default(), ) as Arc, config: AppConfig::default(), parallel_processor: None, @@ -78,14 +89,23 @@ impl FileFsWriteRepository { time::timeout( self.config.timeouts.dir_timeout(), FileSystemUtils::create_dir_with_sync(parent), - ).await - .map_err(|_| FileRepositoryError::StorageError(format!("Timeout creating dir: {}", parent.display())))? + ) + .await + .map_err(|_| { + FileRepositoryError::StorageError(format!( + "Timeout creating dir: {}", + parent.display() + )) + })? .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; } Ok(()) } - async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> FileRepositoryResult { + async fn file_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> FileRepositoryResult { let abs = self.resolve_storage_path(storage_path); if let Some(is_file) = self.metadata_cache.is_file(&abs).await { return Ok(is_file); @@ -96,22 +116,46 @@ impl FileFsWriteRepository { Ok(m.is_file()) } Ok(Err(_)) => Ok(false), - Err(_) => Err(FileRepositoryError::StorageError(format!("Timeout: {}", abs.display()))), + Err(_) => Err(FileRepositoryError::StorageError(format!( + "Timeout: {}", + abs.display() + ))), } } - async fn get_file_metadata_raw(&self, abs_path: &PathBuf) -> FileRepositoryResult<(u64, u64, u64)> { + async fn get_file_metadata_raw( + &self, + abs_path: &PathBuf, + ) -> FileRepositoryResult<(u64, u64, u64)> { if let Some(cached) = self.metadata_cache.get_metadata(abs_path).await - && let (Some(s), Some(c), Some(m)) = (cached.size, cached.created_at, cached.modified_at) { - return Ok((s, c, m)); - } + && let (Some(s), Some(c), Some(m)) = + (cached.size, cached.created_at, cached.modified_at) + { + return Ok((s, c, m)); + } let meta = time::timeout(self.config.timeouts.file_timeout(), fs::metadata(abs_path)) .await - .map_err(|_| FileRepositoryError::StorageError(format!("Timeout: {}", abs_path.display())))? + .map_err(|_| { + FileRepositoryError::StorageError(format!("Timeout: {}", abs_path.display())) + })? .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; let s = meta.len(); - let c = meta.created().map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()).unwrap_or(0); - let m = meta.modified().map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()).unwrap_or(0); + let c = meta + .created() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0); + let m = meta + .modified() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0); let _ = self.metadata_cache.refresh_metadata(abs_path).await; Ok((s, c, m)) } @@ -159,14 +203,19 @@ impl FileFsWriteRepository { Err(_) => 0, }; if self.config.resources.is_large_file(file_size) { - task::spawn_blocking(move || { let _ = std::fs::remove_file(&abs_path); }) - .await - .map_err(|e| FileRepositoryError::Other(e.to_string()))?; + task::spawn_blocking(move || { + let _ = std::fs::remove_file(&abs_path); + }) + .await + .map_err(|e| FileRepositoryError::Other(e.to_string()))?; } else { - time::timeout(self.config.timeouts.file_timeout(), fs::remove_file(&abs_path)) - .await - .map_err(|_| FileRepositoryError::StorageError("Timeout deleting file".into()))? - .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; + time::timeout( + self.config.timeouts.file_timeout(), + fs::remove_file(&abs_path), + ) + .await + .map_err(|_| FileRepositoryError::StorageError("Timeout deleting file".into()))? + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; } Ok(()) } @@ -177,11 +226,14 @@ impl FileFsWriteRepository { match self.id_mapping_service.save_changes().await { Ok(_) => { if let Ok(verified) = self.id_mapping_service.get_path_by_id(id).await - && verified.to_string() == expected_path { - return Ok(()); - } + && verified.to_string() == expected_path + { + return Ok(()); + } if attempt == 3 { - return Err(FileRepositoryError::Other("Failed to verify ID mapping after 3 attempts".into())); + return Err(FileRepositoryError::Other( + "Failed to verify ID mapping after 3 attempts".into(), + )); } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } @@ -189,7 +241,12 @@ impl FileFsWriteRepository { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tracing::warn!("ID mapping save retry {}: {}", attempt, e); } - Err(e) => return Err(FileRepositoryError::Other(format!("Save ID mapping failed: {}", e))), + Err(e) => { + return Err(FileRepositoryError::Other(format!( + "Save ID mapping failed: {}", + e + ))); + } } } Ok(()) @@ -229,49 +286,97 @@ impl FileWritePort for FileFsWriteRepository { content: Vec, ) -> Result { let folder_path = self.resolve_folder_path(&folder_id).await; - let (file_storage_path, actual_name) = self.unique_file_path(&folder_path, &name).await.map_err(map_repo_err)?; + let (file_storage_path, actual_name) = self + .unique_file_path(&folder_path, &name) + .await + .map_err(map_repo_err)?; let abs_path = self.resolve_storage_path(&file_storage_path); - self.ensure_parent_directory(&abs_path).await.map_err(map_repo_err)?; + self.ensure_parent_directory(&abs_path) + .await + .map_err(map_repo_err)?; let content_size = content.len() as u64; // Write strategy based on file size - if self.config.resources.needs_parallel_processing(content_size, &self.config.concurrency) { + if self + .config + .resources + .needs_parallel_processing(content_size, &self.config.concurrency) + { if let Some(proc) = &self.parallel_processor { - proc.write_file_parallel(&abs_path, &content).await.map_err(map_repo_err)?; + proc.write_file_parallel(&abs_path, &content) + .await + .map_err(map_repo_err)?; } else { let proc = ParallelFileProcessor::new(self.config.clone()); - proc.write_file_parallel(&abs_path, &content).await.map_err(map_repo_err)?; + proc.write_file_parallel(&abs_path, &content) + .await + .map_err(map_repo_err)?; } } else if content_size > self.config.resources.large_file_threshold_mb * 1024 * 1024 { - let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::create(&abs_path)) - .await - .map_err(|_| DomainError::internal_error("File", "Timeout creating file"))? - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let mut fh = time::timeout( + self.config.timeouts.file_timeout(), + TokioFile::create(&abs_path), + ) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout creating file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let chunk_size = self.config.resources.chunk_size_bytes; for chunk in content.chunks(chunk_size) { - fh.write_all(chunk).await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.write_all(chunk) + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; } - fh.flush().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; - } else { - let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::create(&abs_path)) + fh.flush() + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + } else { + let mut fh = time::timeout( + self.config.timeouts.file_timeout(), + TokioFile::create(&abs_path), + ) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout creating file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.write_all(&content) + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.flush() .await - .map_err(|_| DomainError::internal_error("File", "Timeout creating file"))? .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - fh.write_all(&content).await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; - fh.flush().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; } - let (size, created_at, modified_at) = self.get_file_metadata_raw(&abs_path).await.map_err(map_repo_err)?; - let mime = if content_type.is_empty() { from_path(&abs_path).first_or_octet_stream().to_string() } else { content_type }; - let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await + let (size, created_at, modified_at) = self + .get_file_metadata_raw(&abs_path) + .await + .map_err(map_repo_err)?; + let mime = if content_type.is_empty() { + from_path(&abs_path).first_or_octet_stream().to_string() + } else { + content_type + }; + let id = self + .id_mapping_service + .get_or_create_id(&file_storage_path) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let path_string = file_storage_path.to_string(); - let file = File::with_timestamps(id.clone(), actual_name, file_storage_path, size, mime, folder_id, created_at, modified_at) - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let file = File::with_timestamps( + id.clone(), + actual_name, + file_storage_path, + size, + mime, + folder_id, + created_at, + modified_at, + ) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - self.persist_id_mapping(&id, &path_string).await.map_err(map_repo_err)?; + self.persist_id_mapping(&id, &path_string) + .await + .map_err(map_repo_err)?; if let Some(parent) = abs_path.parent() { self.metadata_cache.invalidate_directory(parent).await; } @@ -286,45 +391,86 @@ impl FileWritePort for FileFsWriteRepository { mut stream: std::pin::Pin> + Send>>, ) -> Result { let folder_path = self.resolve_folder_path(&folder_id).await; - let (file_storage_path, actual_name) = self.unique_file_path(&folder_path, &name).await.map_err(map_repo_err)?; + let (file_storage_path, actual_name) = self + .unique_file_path(&folder_path, &name) + .await + .map_err(map_repo_err)?; let abs_path = self.resolve_storage_path(&file_storage_path); - self.ensure_parent_directory(&abs_path).await.map_err(map_repo_err)?; + self.ensure_parent_directory(&abs_path) + .await + .map_err(map_repo_err)?; let temp_path = abs_path.with_extension("tmp.upload"); - let mut fh = time::timeout(self.config.timeouts.file_timeout(), TokioFile::create(&temp_path)) - .await - .map_err(|_| DomainError::internal_error("File", "Timeout creating temp file"))? - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let mut fh = time::timeout( + self.config.timeouts.file_timeout(), + TokioFile::create(&temp_path), + ) + .await + .map_err(|_| DomainError::internal_error("File", "Timeout creating temp file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let mut total_bytes: u64 = 0; while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(|e| DomainError::internal_error("File", e.to_string()))?; - fh.write_all(&chunk).await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let chunk = + chunk_result.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.write_all(&chunk) + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; total_bytes += chunk.len() as u64; } - fh.flush().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; - fh.sync_all().await.map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.flush() + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + fh.sync_all() + .await + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; drop(fh); // Atomic rename - fs::rename(&temp_path, &abs_path).await + fs::rename(&temp_path, &abs_path) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - let (size, created_at, modified_at) = self.get_file_metadata_raw(&abs_path).await.map_err(map_repo_err)?; - let mime = if content_type.is_empty() { from_path(&abs_path).first_or_octet_stream().to_string() } else { content_type }; - let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await + let (size, created_at, modified_at) = self + .get_file_metadata_raw(&abs_path) + .await + .map_err(map_repo_err)?; + let mime = if content_type.is_empty() { + from_path(&abs_path).first_or_octet_stream().to_string() + } else { + content_type + }; + let id = self + .id_mapping_service + .get_or_create_id(&file_storage_path) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let path_string = file_storage_path.to_string(); let log_name = actual_name.clone(); - let file = File::with_timestamps(id.clone(), actual_name, file_storage_path, size, mime, folder_id, created_at, modified_at) - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let file = File::with_timestamps( + id.clone(), + actual_name, + file_storage_path, + size, + mime, + folder_id, + created_at, + modified_at, + ) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; - self.persist_id_mapping(&id, &path_string).await.map_err(map_repo_err)?; + self.persist_id_mapping(&id, &path_string) + .await + .map_err(map_repo_err)?; if let Some(parent) = abs_path.parent() { self.metadata_cache.invalidate_directory(parent).await; } - tracing::info!("✅ STREAMING UPLOAD COMPLETE: {} ({} bytes)", log_name, total_bytes); + tracing::info!( + "✅ STREAMING UPLOAD COMPLETE: {} ({} bytes)", + log_name, + total_bytes + ); Ok(file) } @@ -339,57 +485,87 @@ impl FileWritePort for FileFsWriteRepository { if !old_abs.exists() || !old_abs.is_file() { return Err(DomainError::not_found("File", file_id.to_string())); } - let (size, created_at, modified_at) = self.get_file_metadata_raw(&old_abs).await.map_err(map_repo_err)?; - let name = original_path.file_name() + let (size, created_at, modified_at) = self + .get_file_metadata_raw(&old_abs) + .await + .map_err(map_repo_err)?; + let name = original_path + .file_name() .ok_or_else(|| DomainError::internal_error("File", "Invalid path"))?; let mime = from_path(&old_abs).first_or_octet_stream().to_string(); // Build target path let target_folder_path = self.resolve_folder_path(&target_folder_id).await; let new_storage_path = target_folder_path.join(&name); - if self.file_exists_at_storage_path(&new_storage_path).await.map_err(map_repo_err)? { - return Err(DomainError::already_exists("File", - format!("File already exists at {}", new_storage_path.to_string()))); + if self + .file_exists_at_storage_path(&new_storage_path) + .await + .map_err(map_repo_err)? + { + return Err(DomainError::already_exists( + "File", + format!("File already exists at {}", new_storage_path.to_string()), + )); } let new_abs = self.resolve_storage_path(&new_storage_path); - self.ensure_parent_directory(&new_abs).await.map_err(map_repo_err)?; + self.ensure_parent_directory(&new_abs) + .await + .map_err(map_repo_err)?; // Rename time::timeout( self.config.timeouts.file_timeout(), FileSystemUtils::rename_with_sync(&old_abs, &new_abs), - ).await + ) + .await .map_err(|_| DomainError::internal_error("File", "Timeout moving file"))? .map_err(|e| DomainError::internal_error("File", e.to_string()))?; // Update mapping - self.id_mapping_service.update_path(file_id, &new_storage_path).await?; + self.id_mapping_service + .update_path(file_id, &new_storage_path) + .await?; let _ = self.id_mapping_service.save_changes().await; - File::with_timestamps(file_id.to_string(), name, new_storage_path, size, mime, target_folder_id, created_at, modified_at) - .map_err(|e| DomainError::internal_error("File", e.to_string())) + File::with_timestamps( + file_id.to_string(), + name, + new_storage_path, + size, + mime, + target_folder_id, + created_at, + modified_at, + ) + .map_err(|e| DomainError::internal_error("File", e.to_string())) } - async fn rename_file( - &self, - file_id: &str, - new_name: &str, - ) -> Result { + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { // 1. Get current file info let original_path = self.id_mapping_service.get_path_by_id(file_id).await?; let old_abs = self.resolve_storage_path(&original_path); if !old_abs.exists() || !old_abs.is_file() { return Err(DomainError::not_found("File", file_id.to_string())); } - let (size, created_at, modified_at) = self.get_file_metadata_raw(&old_abs).await.map_err(map_repo_err)?; + let (size, created_at, modified_at) = self + .get_file_metadata_raw(&old_abs) + .await + .map_err(map_repo_err)?; // 2. Build new path (same parent directory, different filename) - let parent = original_path.parent() + let parent = original_path + .parent() .unwrap_or_else(|| StoragePath::new(vec![])); let new_storage_path = parent.join(new_name); - if self.file_exists_at_storage_path(&new_storage_path).await.map_err(map_repo_err)? { - return Err(DomainError::already_exists("File", - format!("File already exists: {}", new_name))); + if self + .file_exists_at_storage_path(&new_storage_path) + .await + .map_err(map_repo_err)? + { + return Err(DomainError::already_exists( + "File", + format!("File already exists: {}", new_name), + )); } let new_abs = self.resolve_storage_path(&new_storage_path); let mime = from_path(&new_abs).first_or_octet_stream().to_string(); @@ -398,12 +574,15 @@ impl FileWritePort for FileFsWriteRepository { time::timeout( self.config.timeouts.file_timeout(), FileSystemUtils::rename_with_sync(&old_abs, &new_abs), - ).await + ) + .await .map_err(|_| DomainError::internal_error("File", "Timeout renaming file"))? .map_err(|e| DomainError::internal_error("File", e.to_string()))?; // 4. Update id→path mapping - self.id_mapping_service.update_path(file_id, &new_storage_path).await?; + self.id_mapping_service + .update_path(file_id, &new_storage_path) + .await?; let _ = self.id_mapping_service.save_changes().await; File::with_timestamps( @@ -428,7 +607,9 @@ impl FileWritePort for FileFsWriteRepository { self.metadata_cache.invalidate_directory(parent).await; } - self.delete_file_non_blocking(abs_path).await.map_err(map_repo_err)?; + self.delete_file_non_blocking(abs_path) + .await + .map_err(map_repo_err)?; // Clean up the ID mapping so we don't leave orphaned entries if let Err(e) = self.id_mapping_service.remove_id(id).await { @@ -439,7 +620,11 @@ impl FileWritePort for FileFsWriteRepository { Ok(()) } - async fn update_file_content(&self, file_id: &str, content: Vec) -> Result<(), DomainError> { + async fn update_file_content( + &self, + file_id: &str, + content: Vec, + ) -> Result<(), DomainError> { let storage_path = self.id_mapping_service.get_path_by_id(file_id).await?; let physical_path = self.resolve_storage_path(&storage_path); @@ -460,9 +645,14 @@ impl FileWritePort for FileFsWriteRepository { size: u64, ) -> Result<(File, PathBuf), DomainError> { let folder_path = self.resolve_folder_path(&folder_id).await; - let (file_storage_path, actual_name) = self.unique_file_path(&folder_path, &name).await.map_err(map_repo_err)?; + let (file_storage_path, actual_name) = self + .unique_file_path(&folder_path, &name) + .await + .map_err(map_repo_err)?; let abs_path = self.resolve_storage_path(&file_storage_path); - self.ensure_parent_directory(&abs_path).await.map_err(map_repo_err)?; + self.ensure_parent_directory(&abs_path) + .await + .map_err(map_repo_err)?; let mime = if content_type.is_empty() { from_path(&abs_path).first_or_octet_stream().to_string() @@ -474,12 +664,24 @@ impl FileWritePort for FileFsWriteRepository { .unwrap_or_default() .as_secs(); - let id = self.id_mapping_service.get_or_create_id(&file_storage_path).await + let id = self + .id_mapping_service + .get_or_create_id(&file_storage_path) + .await .map_err(|e| DomainError::internal_error("File", e.to_string()))?; let _ = self.id_mapping_service.save_changes().await; - let file = File::with_timestamps(id.clone(), actual_name, file_storage_path, size, mime, folder_id, now, now) - .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + let file = File::with_timestamps( + id.clone(), + actual_name, + file_storage_path, + size, + mime, + folder_id, + now, + now, + ) + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; tracing::debug!("⚡ Registered deferred file: {} -> {:?}", id, abs_path); Ok((file, abs_path)) @@ -496,17 +698,21 @@ impl FileWritePort for FileFsWriteRepository { // Create trash directory let trash_dir = self.root_path.join(".trash").join("files"); - fs::create_dir_all(&trash_dir).await - .map_err(|e| DomainError::internal_error("File", format!("Failed to create trash dir: {}", e)))?; + fs::create_dir_all(&trash_dir).await.map_err(|e| { + DomainError::internal_error("File", format!("Failed to create trash dir: {}", e)) + })?; // Move file to trash let trash_path = trash_dir.join(file_id); - fs::rename(&abs_path, &trash_path).await - .map_err(|e| DomainError::internal_error("File", format!("Failed to move file to trash: {}", e)))?; + fs::rename(&abs_path, &trash_path).await.map_err(|e| { + DomainError::internal_error("File", format!("Failed to move file to trash: {}", e)) + })?; // Update mapping to trash location let trash_storage_path = StoragePath::from_string(&format!(".trash/files/{}", file_id)); - self.id_mapping_service.update_path(file_id, &trash_storage_path).await?; + self.id_mapping_service + .update_path(file_id, &trash_storage_path) + .await?; let _ = self.id_mapping_service.save_changes().await; // Invalidate cache @@ -515,36 +721,57 @@ impl FileWritePort for FileFsWriteRepository { self.metadata_cache.invalidate_directory(parent).await; } - tracing::debug!("File moved to trash: {} -> {}", file_id, trash_path.display()); + tracing::debug!( + "File moved to trash: {} -> {}", + file_id, + trash_path.display() + ); Ok(()) } - async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError> { + async fn restore_from_trash( + &self, + file_id: &str, + original_path: &str, + ) -> Result<(), DomainError> { // Get current path (should be in trash) let current_storage_path = self.id_mapping_service.get_path_by_id(file_id).await?; let current_abs_path = self.resolve_storage_path(¤t_storage_path); if !current_abs_path.exists() { - return Err(DomainError::not_found("File", format!("File {} not found in trash", file_id))); + return Err(DomainError::not_found( + "File", + format!("File {} not found in trash", file_id), + )); } // Ensure parent directory exists for original location let original_storage_path = StoragePath::from_string(original_path); let original_abs_path = self.resolve_storage_path(&original_storage_path); if let Some(parent) = original_abs_path.parent() { - fs::create_dir_all(parent).await - .map_err(|e| DomainError::internal_error("File", format!("Failed to create parent dir: {}", e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + DomainError::internal_error("File", format!("Failed to create parent dir: {}", e)) + })?; } // Move file back to original location - fs::rename(¤t_abs_path, &original_abs_path).await - .map_err(|e| DomainError::internal_error("File", format!("Failed to restore file: {}", e)))?; + fs::rename(¤t_abs_path, &original_abs_path) + .await + .map_err(|e| { + DomainError::internal_error("File", format!("Failed to restore file: {}", e)) + })?; // Update mapping back to original path - self.id_mapping_service.update_path(file_id, &original_storage_path).await?; + self.id_mapping_service + .update_path(file_id, &original_storage_path) + .await?; let _ = self.id_mapping_service.save_changes().await; - tracing::debug!("File restored from trash: {} -> {}", file_id, original_abs_path.display()); + tracing::debug!( + "File restored from trash: {} -> {}", + file_id, + original_abs_path.display() + ); Ok(()) } @@ -555,7 +782,9 @@ impl FileWritePort for FileFsWriteRepository { // Delete the physical file if it exists if abs_path.exists() { - self.delete_file_non_blocking(abs_path.clone()).await.map_err(map_repo_err)?; + self.delete_file_non_blocking(abs_path.clone()) + .await + .map_err(map_repo_err)?; } // Remove ID mapping @@ -568,4 +797,4 @@ impl FileWritePort for FileFsWriteRepository { tracing::debug!("File permanently deleted: {}", file_id); Ok(()) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/folder_fs_repository.rs b/src/infrastructure/repositories/folder_fs_repository.rs index dd892560..62f24887 100644 --- a/src/infrastructure/repositories/folder_fs_repository.rs +++ b/src/infrastructure/repositories/folder_fs_repository.rs @@ -1,21 +1,21 @@ +use async_trait::async_trait; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; use tokio::fs; use tokio::time::timeout; use crate::domain::entities::folder::{Folder, FolderError}; -use crate::infrastructure::repositories::repository_errors::{ - FolderRepositoryError, FolderRepositoryResult -}; use crate::domain::services::path_service::StoragePath; +use crate::infrastructure::repositories::repository_errors::{ + FolderRepositoryError, FolderRepositoryResult, +}; use crate::infrastructure::services::path_service::PathService; // use crate::application::ports::outbound::IdMappingPort; -use crate::infrastructure::services::id_mapping_service::{IdMappingService, IdMappingError}; use crate::application::services::storage_mediator::StorageMediator; -use crate::domain::repositories::folder_repository::FolderRepository; use crate::common::errors::DomainError; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::infrastructure::services::id_mapping_service::{IdMappingError, IdMappingService}; // To be able to use streams in the list_folders function use tokio_stream; @@ -36,36 +36,33 @@ impl FolderFsRepository { id_mapping_service: Arc, path_service: Arc, ) -> Self { - Self { - root_path, - storage_mediator, + Self { + root_path, + storage_mediator, id_mapping_service, path_service, } } - + /// Returns the root path of the storage pub fn get_root_path(&self) -> &PathBuf { &self.root_path } - + /// Creates a stub repository for initialization purposes /// This is used temporarily during dependency injection setup pub fn new_stub() -> Self { let root_path = PathBuf::from("/tmp"); let path_service = Arc::new(PathService::new(root_path.clone())); - + // Create minimal implementations just to satisfy initialization // Since we can't easily block on an async function in a sync context, create with a stub - let id_mapping_service = Arc::new( - IdMappingService::new_sync(root_path.clone()) - ); - + let id_mapping_service = Arc::new(IdMappingService::new_sync(root_path.clone())); + // Create a self-referential stub (only used for initialization) - let storage_mediator_stub = Arc::new( - crate::application::services::storage_mediator::StubStorageMediator::new() - ); - + let storage_mediator_stub = + Arc::new(crate::application::services::storage_mediator::StubStorageMediator::new()); + Self { root_path, storage_mediator: storage_mediator_stub, @@ -73,23 +70,21 @@ impl FolderFsRepository { path_service, } } - + /// Gets the count of items in a directory efficiently async fn count_directory_items(&self, directory_path: &Path) -> FolderRepositoryResult { use tokio::fs::read_dir; - + // Timeout to avoid blocking let read_dir_timeout = Duration::from_secs(30); - let read_dir_result = timeout( - read_dir_timeout, - read_dir(directory_path) - ).await; - + let read_dir_result = timeout(read_dir_timeout, read_dir(directory_path)).await; + match read_dir_result { Ok(result) => { - let mut entries = result.map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; + let mut entries = + result.map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; let mut count = 0; - + // Count entries manually, skipping hidden/system directories while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); @@ -98,71 +93,99 @@ impl FolderFsRepository { } count += 1; } - + Ok(count) - }, - Err(_) => { - Err(FolderRepositoryError::Other( - format!("Timeout counting items in directory: {}", directory_path.display()) - )) } + Err(_) => Err(FolderRepositoryError::Other(format!( + "Timeout counting items in directory: {}", + directory_path.display() + ))), } } - + /// Resolves a domain storage path to an absolute filesystem path fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { self.path_service.resolve_path(storage_path) } - + /// Returns a reference to the ID mapping service - pub fn id_mapping_service(&self) -> &Arc { + pub fn id_mapping_service( + &self, + ) -> &Arc { &self.id_mapping_service } /// Gets the storage path for a folder by its ID (internal helper) async fn _get_folder_storage_path(&self, id: &str) -> FolderRepositoryResult { - let storage_path = self.id_mapping_service.get_path_by_id(id).await + let storage_path = self + .id_mapping_service + .get_path_by_id(id) + .await .map_err(FolderRepositoryError::from)?; Ok(storage_path) } - + /// Gets a folder path from the ID mapping service pub async fn get_mapped_folder_path(&self, folder_id: &str) -> FolderRepositoryResult { - let storage_path = self.id_mapping_service.get_path_by_id(folder_id).await - .map_err(|e| FolderRepositoryError::StorageError(format!("Failed to get folder path: {}", e)))?; + let storage_path = self + .id_mapping_service + .get_path_by_id(folder_id) + .await + .map_err(|e| { + FolderRepositoryError::StorageError(format!("Failed to get folder path: {}", e)) + })?; Ok(storage_path.to_string()) } - + /// Updates a folder path in the ID mapping service - pub async fn update_mapped_folder_path(&self, folder_id: &str, new_path: &PathBuf) -> FolderRepositoryResult<()> { + pub async fn update_mapped_folder_path( + &self, + folder_id: &str, + new_path: &PathBuf, + ) -> FolderRepositoryResult<()> { let storage_path = StoragePath::from_string(new_path.to_string_lossy().as_ref()); - self.id_mapping_service.update_path(folder_id, &storage_path).await - .map_err(|e| FolderRepositoryError::StorageError(format!("Failed to update folder path: {}", e))) + self.id_mapping_service + .update_path(folder_id, &storage_path) + .await + .map_err(|e| { + FolderRepositoryError::StorageError(format!("Failed to update folder path: {}", e)) + }) } - + /// Removes a folder ID from the ID mapping service pub async fn remove_mapped_folder_id(&self, folder_id: &str) -> FolderRepositoryResult<()> { - self.id_mapping_service.remove_id(folder_id).await - .map_err(|e| FolderRepositoryError::StorageError(format!("Failed to remove folder ID: {}", e))) + self.id_mapping_service + .remove_id(folder_id) + .await + .map_err(|e| { + FolderRepositoryError::StorageError(format!("Failed to remove folder ID: {}", e)) + }) } - + /// Checks if a folder exists at a given storage path - async fn check_folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> FolderRepositoryResult { + async fn check_folder_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> FolderRepositoryResult { let abs_path = self.resolve_storage_path(storage_path); - + // Check if folder exists and is a directory let exists = abs_path.exists() && abs_path.is_dir(); - - tracing::debug!("Checking if folder exists: {} - path: {}", exists, abs_path.display()); - + + tracing::debug!( + "Checking if folder exists: {} - path: {}", + exists, + abs_path.display() + ); + Ok(exists) } - + /// Creates the physical directory on the filesystem async fn create_directory(&self, path: &Path) -> Result<(), std::io::Error> { fs::create_dir_all(path).await } - + /// Helper method to create a Folder entity from a storage path and metadata async fn create_folder_entity( &self, @@ -175,47 +198,46 @@ impl FolderFsRepository { ) -> FolderRepositoryResult { // If timestamps are provided, use them; otherwise, let Folder::new create default timestamps let folder = if let (Some(created), Some(modified)) = (created_at, modified_at) { - Folder::with_timestamps( - id, - name, - storage_path, - parent_id, - created, - modified, - ) + Folder::with_timestamps(id, name, storage_path, parent_id, created, modified) } else { - Folder::new( - id, - name, - storage_path, - parent_id, - ) + Folder::new(id, name, storage_path, parent_id) }; - + // Convert domain error to repository error folder.map_err(|e| match e { - FolderError::InvalidFolderName(name) => - FolderRepositoryError::ValidationError(format!("Invalid folder name: {}", name)), - FolderError::ValidationError(msg) => - FolderRepositoryError::ValidationError(msg), + FolderError::InvalidFolderName(name) => { + FolderRepositoryError::ValidationError(format!("Invalid folder name: {}", name)) + } + FolderError::ValidationError(msg) => FolderRepositoryError::ValidationError(msg), }) } - + /// Extracts folder metadata from a physical path async fn get_folder_metadata(&self, abs_path: &PathBuf) -> FolderRepositoryResult<(u64, u64)> { - let metadata = fs::metadata(&abs_path).await + let metadata = fs::metadata(&abs_path) + .await .map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; - + // Get creation timestamp - let created_at = metadata.created() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + let created_at = metadata + .created() + .map(|time| { + time.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .unwrap_or_else(|_| 0); - + // Get modification timestamp - let modified_at = metadata.modified() - .map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) + let modified_at = metadata + .modified() + .map(|time| { + time.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .unwrap_or_else(|_| 0); - + Ok((created_at, modified_at)) } } @@ -226,7 +248,9 @@ impl From for FolderRepositoryError { match err { IdMappingError::NotFound(id) => FolderRepositoryError::NotFound(id), IdMappingError::IoError(e) => FolderRepositoryError::StorageError(e.to_string()), - IdMappingError::Timeout(msg) => FolderRepositoryError::StorageError(format!("Timeout: {}", msg)), + IdMappingError::Timeout(msg) => { + FolderRepositoryError::StorageError(format!("Timeout: {}", msg)) + } _ => FolderRepositoryError::StorageError(err.to_string()), } } @@ -247,209 +271,272 @@ impl Clone for FolderFsRepository { #[async_trait] impl FolderRepository for FolderFsRepository { - async fn create_folder(&self, name: String, parent_id: Option) -> Result { + async fn create_folder( + &self, + name: String, + parent_id: Option, + ) -> Result { // Get the parent folder path (if any) let parent_storage_path = match &parent_id { - Some(id) => { - match self._get_folder_storage_path(id).await { - Ok(path) => { - tracing::info!("Using folder path: {:?} for parent_id: {:?}", path.to_string(), id); - Some(path) - }, - Err(e) => { - tracing::error!("Error getting parent folder: {}", e); - return Err(DomainError::from(e)); - }, + Some(id) => match self._get_folder_storage_path(id).await { + Ok(path) => { + tracing::info!( + "Using folder path: {:?} for parent_id: {:?}", + path.to_string(), + id + ); + Some(path) + } + Err(e) => { + tracing::error!("Error getting parent folder: {}", e); + return Err(DomainError::from(e)); } }, None => None, }; - + // Create the storage path for the new folder let folder_storage_path = match parent_storage_path { Some(parent) => parent.join(&name), None => StoragePath::from_string(&name), }; - tracing::info!("Creating folder at path: {:?}", folder_storage_path.to_string()); - + tracing::info!( + "Creating folder at path: {:?}", + folder_storage_path.to_string() + ); + // Check if folder already exists - if self.check_folder_exists_at_storage_path(&folder_storage_path).await.map_err(DomainError::from)? { - return Err(DomainError::already_exists("Folder", folder_storage_path.to_string())); + if self + .check_folder_exists_at_storage_path(&folder_storage_path) + .await + .map_err(DomainError::from)? + { + return Err(DomainError::already_exists( + "Folder", + folder_storage_path.to_string(), + )); } - + // Create the physical directory let abs_path = self.resolve_storage_path(&folder_storage_path); - self.create_directory(&abs_path).await + self.create_directory(&abs_path) + .await .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; - + // Create and return the folder entity with a persisted ID - let id = self.id_mapping_service.get_or_create_id(&folder_storage_path).await + let id = self + .id_mapping_service + .get_or_create_id(&folder_storage_path) + .await .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; - let folder = self.create_folder_entity( - id.clone(), - name.clone(), - folder_storage_path.clone(), - parent_id.clone(), - None, - None, - ).await.map_err(DomainError::from)?; - + let folder = self + .create_folder_entity( + id.clone(), + name.clone(), + folder_storage_path.clone(), + parent_id.clone(), + None, + None, + ) + .await + .map_err(DomainError::from)?; + // Ensure ID mapping is persisted let save_result = self.id_mapping_service.save_changes().await; if let Err(e) = &save_result { tracing::error!("Failed to save ID mapping for folder {}: {}", id, e); } else { - tracing::info!("Successfully saved ID mapping for folder ID: {} -> path: {} (name: {})", - id, folder_storage_path.to_string(), name); + tracing::info!( + "Successfully saved ID mapping for folder ID: {} -> path: {} (name: {})", + id, + folder_storage_path.to_string(), + name + ); } save_result?; - + tracing::debug!("Created folder with ID: {}", folder.id()); Ok(folder) } - + async fn get_folder(&self, id: &str) -> Result { tracing::debug!("Looking for folder with ID: {}", id); - + // Find path by ID using the mapping service let storage_path = self.id_mapping_service.get_path_by_id(id).await?; - + // Check if folder exists physically let abs_path = self.resolve_storage_path(&storage_path); if !abs_path.exists() || !abs_path.is_dir() { tracing::error!("Folder not found at path: {}", abs_path.display()); - return Err(DomainError::not_found("Folder", format!("Folder {} not found at {}", id, storage_path.to_string()))); + return Err(DomainError::not_found( + "Folder", + format!("Folder {} not found at {}", id, storage_path.to_string()), + )); } - + // Get folder metadata - let (created_at, modified_at) = self.get_folder_metadata(&abs_path).await.map_err(DomainError::from)?; - + let (created_at, modified_at) = self + .get_folder_metadata(&abs_path) + .await + .map_err(DomainError::from)?; + // Get folder name from the storage path let name = match storage_path.file_name() { Some(name) => name, None => { tracing::error!("Invalid folder path: {}", storage_path.to_string()); - return Err(DomainError::validation_error(format!("Invalid path: {}", storage_path.to_string()))); + return Err(DomainError::validation_error(format!( + "Invalid path: {}", + storage_path.to_string() + ))); } }; - + // Determine parent ID if any let parent = storage_path.parent(); let parent_id: Option = if parent.is_none() || parent.as_ref().unwrap().is_empty() { None } else { - self.id_mapping_service.get_or_create_id(parent.as_ref().unwrap()).await.ok() + self.id_mapping_service + .get_or_create_id(parent.as_ref().unwrap()) + .await + .ok() }; - + // Create folder entity - let folder = self.create_folder_entity( - id.to_string(), - name, - storage_path, - parent_id, - Some(created_at), - Some(modified_at), - ).await.map_err(DomainError::from)?; - + let folder = self + .create_folder_entity( + id.to_string(), + name, + storage_path, + parent_id, + Some(created_at), + Some(modified_at), + ) + .await + .map_err(DomainError::from)?; + Ok(folder) } - + async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { // Check if the physical directory exists let abs_path = self.resolve_storage_path(storage_path); if !abs_path.exists() || !abs_path.is_dir() { return Err(DomainError::not_found("Folder", storage_path.to_string())); } - + // Extract folder name from storage path let name = match storage_path.file_name() { Some(name) => name, None => { - return Err(DomainError::validation_error(format!("Invalid path: {}", storage_path.to_string()))); + return Err(DomainError::validation_error(format!( + "Invalid path: {}", + storage_path.to_string() + ))); } }; - + // Determine parent ID if any let parent = storage_path.parent(); let parent_id: Option = if parent.is_none() || parent.as_ref().unwrap().is_empty() { None } else { - self.id_mapping_service.get_or_create_id(parent.as_ref().unwrap()).await.ok() + self.id_mapping_service + .get_or_create_id(parent.as_ref().unwrap()) + .await + .ok() }; - + // Get folder metadata - let (created_at, modified_at) = self.get_folder_metadata(&abs_path).await.map_err(DomainError::from)?; - + let (created_at, modified_at) = self + .get_folder_metadata(&abs_path) + .await + .map_err(DomainError::from)?; + // Get or create an ID for this path - let id = self.id_mapping_service.get_or_create_id(storage_path).await?; - tracing::debug!("Found folder with path: {:?}, assigned ID: {}", storage_path.to_string(), id); - + let id = self + .id_mapping_service + .get_or_create_id(storage_path) + .await?; + tracing::debug!( + "Found folder with path: {:?}, assigned ID: {}", + storage_path.to_string(), + id + ); + // Create folder entity - let folder = self.create_folder_entity( - id, - name, - storage_path.clone(), - parent_id, - Some(created_at), - Some(modified_at), - ).await.map_err(DomainError::from)?; - + let folder = self + .create_folder_entity( + id, + name, + storage_path.clone(), + parent_id, + Some(created_at), + Some(modified_at), + ) + .await + .map_err(DomainError::from)?; + // Ensure ID mapping is persisted self.id_mapping_service.save_changes().await?; - + Ok(folder) } - + async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { use futures::stream::StreamExt; - use tokio::time::{timeout, Duration}; - + use tokio::time::{Duration, timeout}; + tracing::info!("Listing folders in parent_id: {:?}", parent_id); - + // Get the parent storage path let parent_storage_path = match parent_id { - Some(id) => { - match self._get_folder_storage_path(id).await { - Ok(path) => { - tracing::info!("Found parent folder with path: {:?}", path.to_string()); - path - }, - Err(e) => { - tracing::error!("Error getting parent folder by ID: {}: {}", id, e); - return Ok(Vec::new()); - }, + Some(id) => match self._get_folder_storage_path(id).await { + Ok(path) => { + tracing::info!("Found parent folder with path: {:?}", path.to_string()); + path + } + Err(e) => { + tracing::error!("Error getting parent folder by ID: {}: {}", id, e); + return Ok(Vec::new()); } }, None => StoragePath::root(), }; - + // Get the absolute folder path let abs_parent_path = self.resolve_storage_path(&parent_storage_path); tracing::info!("Absolute parent path: {:?}", &abs_parent_path); - + // Ensure the directory exists if !abs_parent_path.exists() || !abs_parent_path.is_dir() { - tracing::error!("Directory does not exist or is not a directory: {:?}", &abs_parent_path); + tracing::error!( + "Directory does not exist or is not a directory: {:?}", + &abs_parent_path + ); return Ok(Vec::new()); } - + // Read the directory with a timeout let read_dir_timeout = Duration::from_secs(30); - let read_dir_result = match timeout( - read_dir_timeout, - fs::read_dir(&abs_parent_path) - ).await { - Ok(result) => result.map_err(|e| DomainError::internal_error("Folder", e.to_string()))?, + let read_dir_result = match timeout(read_dir_timeout, fs::read_dir(&abs_parent_path)).await + { + Ok(result) => { + result.map_err(|e| DomainError::internal_error("Folder", e.to_string()))? + } Err(_) => { - return Err(DomainError::internal_error("Folder", - format!("Timeout reading directory: {}", abs_parent_path.display()) + return Err(DomainError::internal_error( + "Folder", + format!("Timeout reading directory: {}", abs_parent_path.display()), )); } }; - + let mut folders = Vec::new(); let mut entries = tokio_stream::wrappers::ReadDirStream::new(read_dir_result); - + while let Some(entry_result) = entries.next().await { let entry = match entry_result { Ok(e) => e, @@ -458,44 +545,47 @@ impl FolderRepository for FolderFsRepository { continue; } }; - + let metadata = match entry.metadata().await { Ok(m) => m, Err(err) => { - tracing::error!("Error getting metadata for {}: {}", entry.path().display(), err); + tracing::error!( + "Error getting metadata for {}: {}", + entry.path().display(), + err + ); continue; } }; - + if !metadata.is_dir() { continue; } - + let folder_name = entry.file_name().to_string_lossy().to_string(); - + // Skip hidden/system directories (e.g. .blobs, .trash, .dedup_temp) if folder_name.starts_with('.') { continue; } - + let folder_storage_path = parent_storage_path.join(&folder_name); - + let get_folder_timeout = Duration::from_secs(5); let folder_result = timeout( get_folder_timeout, - self.get_folder_by_path(&folder_storage_path) - ).await; - + self.get_folder_by_path(&folder_storage_path), + ) + .await; + match folder_result { - Ok(result) => { - match result { - Ok(folder) => { - tracing::debug!("Found folder: {}", folder.name()); - folders.push(folder); - }, - Err(e) => { - tracing::warn!("Could not get folder entity for {}: {}", folder_name, e); - } + Ok(result) => match result { + Ok(folder) => { + tracing::debug!("Found folder: {}", folder.name()); + folders.push(folder); + } + Err(e) => { + tracing::warn!("Could not get folder entity for {}: {}", folder_name, e); } }, Err(_) => { @@ -503,47 +593,49 @@ impl FolderRepository for FolderFsRepository { } } } - + if let Err(e) = self.id_mapping_service.save_changes().await { tracing::error!("Failed to save ID mappings: {}", e); } - + tracing::info!("Found {} folders in parent {:?}", folders.len(), parent_id); Ok(folders) } - + async fn list_folders_paginated( - &self, + &self, parent_id: Option<&str>, offset: usize, limit: usize, - include_total: bool + include_total: bool, ) -> Result<(Vec, Option), DomainError> { use futures::stream::StreamExt; - use tokio::time::{timeout, Duration}; - - tracing::info!("Listing folders in parent_id: {:?} with pagination (offset={}, limit={})", - parent_id, offset, limit); - + use tokio::time::{Duration, timeout}; + + tracing::info!( + "Listing folders in parent_id: {:?} with pagination (offset={}, limit={})", + parent_id, + offset, + limit + ); + let parent_storage_path = match parent_id { - Some(id) => { - match self._get_folder_storage_path(id).await { - Ok(path) => path, - Err(e) => { - tracing::error!("Error getting parent folder by ID: {}: {}", id, e); - return Ok((Vec::new(), Some(0))); - }, + Some(id) => match self._get_folder_storage_path(id).await { + Ok(path) => path, + Err(e) => { + tracing::error!("Error getting parent folder by ID: {}: {}", id, e); + return Ok((Vec::new(), Some(0))); } }, None => StoragePath::root(), }; - + let abs_parent_path = self.resolve_storage_path(&parent_storage_path); - + if !abs_parent_path.exists() || !abs_parent_path.is_dir() { return Ok((Vec::new(), Some(0))); } - + let total_count = if include_total { match self.count_directory_items(&abs_parent_path).await { Ok(count) => Some(count), @@ -555,34 +647,35 @@ impl FolderRepository for FolderFsRepository { } else { None }; - + let read_dir_timeout = Duration::from_secs(30); - let read_dir_result = match timeout( - read_dir_timeout, - fs::read_dir(&abs_parent_path) - ).await { - Ok(result) => result.map_err(|e| DomainError::internal_error("Folder", e.to_string()))?, + let read_dir_result = match timeout(read_dir_timeout, fs::read_dir(&abs_parent_path)).await + { + Ok(result) => { + result.map_err(|e| DomainError::internal_error("Folder", e.to_string()))? + } Err(_) => { - return Err(DomainError::internal_error("Folder", - format!("Timeout reading directory: {}", abs_parent_path.display()) + return Err(DomainError::internal_error( + "Folder", + format!("Timeout reading directory: {}", abs_parent_path.display()), )); } }; - + let mut entries = tokio_stream::wrappers::ReadDirStream::new(read_dir_result); let mut folders = Vec::new(); let mut current_idx = 0; - + while let Some(entry_result) = entries.next().await { if current_idx < offset { current_idx += 1; continue; } - + if folders.len() >= limit { break; } - + let entry = match entry_result { Ok(e) => e, Err(err) => { @@ -591,7 +684,7 @@ impl FolderRepository for FolderFsRepository { continue; } }; - + let file_type = match entry.file_type().await { Ok(ft) => ft, Err(e) => { @@ -600,19 +693,19 @@ impl FolderRepository for FolderFsRepository { continue; } }; - + if !file_type.is_dir() { current_idx += 1; continue; } - + // Skip hidden/system directories (e.g. .blobs, .trash, .dedup_temp) let dir_name = entry.file_name().to_string_lossy().to_string(); if dir_name.starts_with('.') { current_idx += 1; continue; } - + let path = entry.path(); let rel_path = match path.strip_prefix(&self.root_path) { Ok(rel) => StoragePath::from(rel.to_path_buf()), @@ -622,128 +715,174 @@ impl FolderRepository for FolderFsRepository { continue; } }; - - let folder_result = timeout( - Duration::from_secs(10), - self.get_folder_by_path(&rel_path) - ).await; - + + let folder_result = + timeout(Duration::from_secs(10), self.get_folder_by_path(&rel_path)).await; + match folder_result { Ok(result) => match result { Ok(folder) => { folders.push(folder); - }, + } Err(e) => { - tracing::error!("Error getting folder by path: {}: {}", rel_path.to_string(), e); + tracing::error!( + "Error getting folder by path: {}: {}", + rel_path.to_string(), + e + ); } }, Err(_) => { tracing::error!("Timeout getting folder by path: {}", rel_path.to_string()); } } - + current_idx += 1; } - + if !folders.is_empty() - && let Err(e) = self.id_mapping_service.save_changes().await { - tracing::error!("Error saving ID mappings: {}", e); - } - + && let Err(e) = self.id_mapping_service.save_changes().await + { + tracing::error!("Error saving ID mappings: {}", e); + } + Ok((folders, total_count)) } - + async fn rename_folder(&self, id: &str, new_name: String) -> Result { let original_folder = self.get_folder(id).await?; - tracing::debug!("Renaming folder with ID: {}, Name: {}", id, original_folder.name()); - - let renamed_folder = original_folder.with_name(new_name) + tracing::debug!( + "Renaming folder with ID: {}, Name: {}", + id, + original_folder.name() + ); + + let renamed_folder = original_folder + .with_name(new_name) .map_err(|e| DomainError::validation_error(e.to_string()))?; - - if self.check_folder_exists_at_storage_path(renamed_folder.storage_path()).await.map_err(DomainError::from)? { - return Err(DomainError::already_exists("Folder", renamed_folder.storage_path().to_string())); + + if self + .check_folder_exists_at_storage_path(renamed_folder.storage_path()) + .await + .map_err(DomainError::from)? + { + return Err(DomainError::already_exists( + "Folder", + renamed_folder.storage_path().to_string(), + )); } - + let abs_old_path = self.resolve_storage_path(original_folder.storage_path()); let abs_new_path = self.resolve_storage_path(renamed_folder.storage_path()); - - fs::rename(&abs_old_path, &abs_new_path).await + + fs::rename(&abs_old_path, &abs_new_path) + .await .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; - - self.id_mapping_service.update_path(id, renamed_folder.storage_path()).await?; + + self.id_mapping_service + .update_path(id, renamed_folder.storage_path()) + .await?; self.id_mapping_service.save_changes().await?; - - tracing::debug!("Folder renamed successfully: ID={}, New name={}", id, renamed_folder.name()); + + tracing::debug!( + "Folder renamed successfully: ID={}, New name={}", + id, + renamed_folder.name() + ); Ok(renamed_folder) } - - async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result { + + async fn move_folder( + &self, + id: &str, + new_parent_id: Option<&str>, + ) -> Result { let original_folder = self.get_folder(id).await?; - tracing::debug!("Moving folder with ID: {}, Name: {}", id, original_folder.name()); - + tracing::debug!( + "Moving folder with ID: {}, Name: {}", + id, + original_folder.name() + ); + if original_folder.parent_id() == new_parent_id { tracing::info!("Folder is already in the target parent, no need to move"); return Ok(original_folder); } - + let target_parent_storage_path = match new_parent_id { - Some(parent_id) => { - match self._get_folder_storage_path(parent_id).await { - Ok(path) => Some(path), - Err(e) => { - return Err(DomainError::internal_error("Folder", - format!("Could not get target folder: {}", e) - )); - } + Some(parent_id) => match self._get_folder_storage_path(parent_id).await { + Ok(path) => Some(path), + Err(e) => { + return Err(DomainError::internal_error( + "Folder", + format!("Could not get target folder: {}", e), + )); } }, - None => None + None => None, }; - + let new_parent_id_option = new_parent_id.map(String::from); - let moved_folder = original_folder.with_parent(new_parent_id_option, target_parent_storage_path) + let moved_folder = original_folder + .with_parent(new_parent_id_option, target_parent_storage_path) .map_err(|e| DomainError::validation_error(e.to_string()))?; - - if self.check_folder_exists_at_storage_path(moved_folder.storage_path()).await.map_err(DomainError::from)? { - return Err(DomainError::already_exists("Folder", - format!("Folder already exists at destination: {}", moved_folder.storage_path().to_string()) + + if self + .check_folder_exists_at_storage_path(moved_folder.storage_path()) + .await + .map_err(DomainError::from)? + { + return Err(DomainError::already_exists( + "Folder", + format!( + "Folder already exists at destination: {}", + moved_folder.storage_path().to_string() + ), )); } - + let old_abs_path = self.resolve_storage_path(original_folder.storage_path()); let new_abs_path = self.resolve_storage_path(moved_folder.storage_path()); - + if let Some(parent) = new_abs_path.parent() { - fs::create_dir_all(parent).await + fs::create_dir_all(parent) + .await .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; } - - fs::rename(&old_abs_path, &new_abs_path).await + + fs::rename(&old_abs_path, &new_abs_path) + .await .map_err(|e| DomainError::internal_error("Folder", e.to_string()))?; - - self.id_mapping_service.update_path(id, moved_folder.storage_path()).await?; + + self.id_mapping_service + .update_path(id, moved_folder.storage_path()) + .await?; self.id_mapping_service.save_changes().await?; - - tracing::debug!("Folder moved successfully: ID={}, New path={:?}", id, moved_folder.storage_path().to_string()); + + tracing::debug!( + "Folder moved successfully: ID={}, New path={:?}", + id, + moved_folder.storage_path().to_string() + ); Ok(moved_folder) } - + async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { - use tokio::time::{timeout, Duration}; - + use tokio::time::{Duration, timeout}; + let folder = self.get_folder(id).await?; let folder_name = folder.name().to_string(); let storage_path = folder.storage_path().clone(); - + tracing::info!("Deleting folder with ID: {}, Name: {}", id, folder_name); - + let abs_path = self.resolve_storage_path(&storage_path); let path_for_display = abs_path.display().to_string(); let path_for_deletion = abs_path.clone(); - + let delete_task = tokio::spawn(async move { tracing::debug!("Starting removal of folder: {}", path_for_display); - + let path_for_counting = path_for_deletion.clone(); let entry_count = tokio::task::spawn_blocking(move || { let mut count = 0; @@ -756,94 +895,116 @@ impl FolderRepository for FolderFsRepository { } } count - }).await.unwrap_or(0); - + }) + .await + .unwrap_or(0); + if entry_count > 1000 { tracing::info!("Large folder detected with >1000 entries, using blocking removal"); let path_for_large_removal = path_for_deletion.clone(); tokio::task::spawn_blocking(move || { if let Err(e) = std::fs::remove_dir_all(&path_for_large_removal) { tracing::error!("Error removing large directory: {}", e); - return Err(std::io::Error::other( - format!("Failed to remove large directory: {}", e) - )); + return Err(std::io::Error::other(format!( + "Failed to remove large directory: {}", + e + ))); } Ok(()) - }).await.unwrap_or_else(|e| { - Err(std::io::Error::other( - format!("Task panicked during directory removal: {}", e) - )) + }) + .await + .unwrap_or_else(|e| { + Err(std::io::Error::other(format!( + "Task panicked during directory removal: {}", + e + ))) }) } else { fs::remove_dir_all(&path_for_deletion).await } }); - + const DELETE_TIMEOUT_SECS: u64 = 60; - - let delete_result = timeout( - Duration::from_secs(DELETE_TIMEOUT_SECS), - delete_task - ).await; - + + let delete_result = timeout(Duration::from_secs(DELETE_TIMEOUT_SECS), delete_task).await; + match delete_result { - Ok(task_result) => { - match task_result { - Ok(fs_result) => { - if let Err(e) = fs_result { - return Err(DomainError::internal_error("Folder", e.to_string())); - } - }, - Err(join_err) => { - return Err(DomainError::internal_error("Folder", - format!("Task panicked during folder deletion: {}", join_err) - )); + Ok(task_result) => match task_result { + Ok(fs_result) => { + if let Err(e) = fs_result { + return Err(DomainError::internal_error("Folder", e.to_string())); } } + Err(join_err) => { + return Err(DomainError::internal_error( + "Folder", + format!("Task panicked during folder deletion: {}", join_err), + )); + } }, Err(_) => { tracing::warn!("Timeout waiting for folder deletion, continuing with ID removal"); } } - + const MAPPING_TIMEOUT_SECS: u64 = 5; let remove_id_result = timeout( Duration::from_secs(MAPPING_TIMEOUT_SECS), - self.id_mapping_service.remove_id(id) - ).await; - + self.id_mapping_service.remove_id(id), + ) + .await; + match remove_id_result { Ok(result) => result?, Err(_) => { - return Err(DomainError::internal_error("Folder", - "Timeout removing folder ID from mapping".to_string() + return Err(DomainError::internal_error( + "Folder", + "Timeout removing folder ID from mapping".to_string(), )); } } - + let _ = self.id_mapping_service.save_changes().await; - - tracing::info!("Folder deleted successfully: ID={}, Name={}", id, folder_name); + + tracing::info!( + "Folder deleted successfully: ID={}, Name={}", + id, + folder_name + ); Ok(()) } - + async fn folder_exists(&self, storage_path: &StoragePath) -> Result { - self.check_folder_exists_at_storage_path(storage_path).await.map_err(DomainError::from) + self.check_folder_exists_at_storage_path(storage_path) + .await + .map_err(DomainError::from) } - + async fn get_folder_path(&self, id: &str) -> Result { - self._get_folder_storage_path(id).await.map_err(DomainError::from) + self._get_folder_storage_path(id) + .await + .map_err(DomainError::from) } async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { - self._trash_move_to_trash(folder_id).await.map_err(DomainError::from) + self._trash_move_to_trash(folder_id) + .await + .map_err(DomainError::from) } - async fn restore_from_trash(&self, folder_id: &str, original_path: &str) -> Result<(), DomainError> { - self._trash_restore_from_trash(folder_id, original_path).await.map_err(DomainError::from) + async fn restore_from_trash( + &self, + folder_id: &str, + original_path: &str, + ) -> Result<(), DomainError> { + self._trash_restore_from_trash(folder_id, original_path) + .await + .map_err(DomainError::from) } async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> { - self._trash_delete_folder_permanently(folder_id).await.map_err(DomainError::from) + self._trash_delete_folder_permanently(folder_id) + .await + .map_err(DomainError::from) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/folder_fs_repository_trash.rs b/src/infrastructure/repositories/folder_fs_repository_trash.rs index 6a925083..78913a1e 100644 --- a/src/infrastructure/repositories/folder_fs_repository_trash.rs +++ b/src/infrastructure/repositories/folder_fs_repository_trash.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use tokio::fs; use tracing::{debug, error}; -use crate::infrastructure::repositories::repository_errors::FolderRepositoryResult; use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository; +use crate::infrastructure::repositories::repository_errors::FolderRepositoryResult; // This file contains the implementation of trash-related methods // for the FolderFsRepository folder repository @@ -14,17 +14,18 @@ impl FolderFsRepository { fn get_trash_dir(&self) -> PathBuf { self.get_root_path().join(".trash").join("folders") } - + // Creates a unique path in the trash for the folder async fn create_trash_folder_path(&self, folder_id: &str) -> FolderRepositoryResult { let trash_dir = self.get_trash_dir(); - + // Ensure the trash directory exists if !trash_dir.exists() { - fs::create_dir_all(&trash_dir).await + fs::create_dir_all(&trash_dir) + .await .map_err(|e| FolderRepositoryError::StorageError(e.to_string()))?; } - + // Create a unique path for the folder in the trash Ok(trash_dir.join(folder_id)) } @@ -37,7 +38,7 @@ impl FolderFsRepository { /// Helper method that will be used for trash functionality pub(crate) async fn _trash_move_to_trash(&self, folder_id: &str) -> FolderRepositoryResult<()> { debug!("Moving folder to trash: {}", folder_id); - + // Get the physical path of the folder let folder_path = match self.get_mapped_folder_path(folder_id).await { Ok(path) => path, @@ -46,41 +47,55 @@ impl FolderFsRepository { return Err(e); } }; - + let folder_path_buf = PathBuf::from(folder_path.to_string()); - + // Verify the folder exists if !folder_path_buf.exists() { - return Err(FolderRepositoryError::NotFound(format!("Folder not found: {}", folder_id))); + return Err(FolderRepositoryError::NotFound(format!( + "Folder not found: {}", + folder_id + ))); } - + // Create directory in the trash let trash_folder_path = self.create_trash_folder_path(folder_id).await?; - + // Physically move the folder to the trash match fs::rename(&folder_path_buf, &trash_folder_path).await { Ok(_) => { - debug!("Folder moved to trash: {} -> {}", folder_path_buf.display(), trash_folder_path.display()); - + debug!( + "Folder moved to trash: {} -> {}", + folder_path_buf.display(), + trash_folder_path.display() + ); + // Update the mapping to the new path in the trash - if let Err(e) = self.update_mapped_folder_path(folder_id, &trash_folder_path).await { + if let Err(e) = self + .update_mapped_folder_path(folder_id, &trash_folder_path) + .await + { error!("Error updating folder mapping in trash: {}", e); return Err(e); } - + Ok(()) - }, + } Err(e) => { error!("Error moving folder to trash: {}", e); Err(FolderRepositoryError::StorageError(e.to_string())) } } } - + /// Restores a folder from the trash to its original location - pub(crate) async fn _trash_restore_from_trash(&self, folder_id: &str, original_path: &str) -> FolderRepositoryResult<()> { + pub(crate) async fn _trash_restore_from_trash( + &self, + folder_id: &str, + original_path: &str, + ) -> FolderRepositoryResult<()> { debug!("Restoring folder {} to {}", folder_id, original_path); - + // Get the current path in the trash let current_path = match self.get_mapped_folder_path(folder_id).await { Ok(path) => PathBuf::from(path), @@ -89,44 +104,54 @@ impl FolderFsRepository { return Err(e); } }; - + // Convert the original path to PathBuf let original_path_buf = PathBuf::from(original_path); - + // Ensure the destination parent directory exists if let Some(parent) = original_path_buf.parent() - && !parent.exists() { - fs::create_dir_all(parent).await - .map_err(|e| { - error!("Error creating parent directory for restoration: {}", e); - FolderRepositoryError::StorageError(e.to_string()) - })?; - } - + && !parent.exists() + { + fs::create_dir_all(parent).await.map_err(|e| { + error!("Error creating parent directory for restoration: {}", e); + FolderRepositoryError::StorageError(e.to_string()) + })?; + } + // Move the folder from the trash to its original location match fs::rename(¤t_path, &original_path_buf).await { Ok(_) => { - debug!("Folder restored: {} -> {}", current_path.display(), original_path_buf.display()); - + debug!( + "Folder restored: {} -> {}", + current_path.display(), + original_path_buf.display() + ); + // Update the mapping to the original path - if let Err(e) = self.update_mapped_folder_path(folder_id, &original_path_buf).await { + if let Err(e) = self + .update_mapped_folder_path(folder_id, &original_path_buf) + .await + { error!("Error updating restored folder mapping: {}", e); return Err(e); } - + Ok(()) - }, + } Err(e) => { error!("Error restoring folder: {}", e); Err(FolderRepositoryError::StorageError(e.to_string())) } } } - + /// Permanently deletes a folder (used by the trash) - pub(crate) async fn _trash_delete_folder_permanently(&self, folder_id: &str) -> FolderRepositoryResult<()> { + pub(crate) async fn _trash_delete_folder_permanently( + &self, + folder_id: &str, + ) -> FolderRepositoryResult<()> { debug!("Permanently deleting folder: {}", folder_id); - + // Similar to delete_folder but without additional validations let folder_path = match self.get_mapped_folder_path(folder_id).await { Ok(path) => PathBuf::from(path), @@ -135,13 +160,13 @@ impl FolderFsRepository { return Err(e); } }; - + // Delete the folder recursively if folder_path.exists() { match fs::remove_dir_all(&folder_path).await { Ok(_) => { debug!("Folder permanently deleted: {}", folder_path.display()); - }, + } Err(e) => { error!("Error permanently deleting folder: {}", e); // Don't report error if the folder no longer exists @@ -151,17 +176,17 @@ impl FolderFsRepository { } } } - + // Remove the mapping if let Err(e) = self.remove_mapped_folder_id(folder_id).await { error!("Error removing folder mapping: {}", e); return Err(e); } - + debug!("Folder permanently deleted successfully: {}", folder_id); Ok(()) } } // Re-exports needed by the compiler -use crate::infrastructure::repositories::repository_errors::FolderRepositoryError; \ No newline at end of file +use crate::infrastructure::repositories::repository_errors::FolderRepositoryError; diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 95cafcfb..4823837f 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -3,19 +3,19 @@ pub mod parallel_file_processor; pub mod repository_errors; // Repositorios CQRS (Read/Write) + composite +pub mod composite_file_repository; pub mod file_fs_read_repository; pub mod file_fs_write_repository; -pub mod composite_file_repository; -pub mod trash_fs_repository; pub mod folder_fs_repository_trash; pub mod share_fs_repository; +pub mod trash_fs_repository; // Repositorios PostgreSQL pub mod pg; // Re-exportar para facilitar acceso +pub use composite_file_repository::CompositeFileRepository; pub use file_fs_read_repository::FileFsReadRepository; pub use file_fs_write_repository::FileFsWriteRepository; -pub use composite_file_repository::CompositeFileRepository; -pub use pg::{UserPgRepository, SessionPgRepository}; +pub use pg::{SessionPgRepository, UserPgRepository}; diff --git a/src/infrastructure/repositories/parallel_file_processor.rs b/src/infrastructure/repositories/parallel_file_processor.rs index 06612576..c7c45562 100644 --- a/src/infrastructure/repositories/parallel_file_processor.rs +++ b/src/infrastructure/repositories/parallel_file_processor.rs @@ -1,13 +1,13 @@ +use bytes::{Bytes, BytesMut}; +use futures::future::join_all; +use std::io::{self, SeekFrom}; use std::path::PathBuf; use std::sync::Arc; -use std::io::{self, SeekFrom}; use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::{Mutex, Semaphore}; use tokio::task; -use tokio::sync::{Semaphore, Mutex}; -use futures::future::join_all; -use tracing::{info, debug, error}; -use bytes::{Bytes, BytesMut}; +use tracing::{debug, error, info}; use crate::common::config::AppConfig; use crate::infrastructure::repositories::repository_errors::FileRepositoryError; @@ -39,11 +39,11 @@ impl BytesBufferPool { max_buffers, } } - + /// Get a buffer from the pool or create a new one pub async fn get_buffer(&self) -> BytesMut { let mut buffers = self.buffers.lock().await; - + if let Some(mut buffer) = buffers.pop() { // Reuse existing buffer buffer.clear(); // Keep capacity, clear content @@ -53,14 +53,14 @@ impl BytesBufferPool { BytesMut::with_capacity(self.buffer_size) } } - + /// Return a buffer to the pool for reuse pub async fn return_buffer(&self, mut buffer: BytesMut) { // Reset the buffer for reuse buffer.clear(); - + let mut buffers = self.buffers.lock().await; - + // Only keep up to max_buffers if buffers.len() < self.max_buffers { buffers.push(buffer); @@ -85,12 +85,12 @@ impl ParallelFileProcessor { /// Creates a new processor instance pub fn new(config: AppConfig) -> Self { let concurrency_limiter = Arc::new(Semaphore::new(config.concurrency.max_concurrent_io)); - + // Create BytesMut pool for efficient operations let chunk_size = config.resources.chunk_size_bytes; let max_chunks = config.concurrency.max_parallel_chunks; let bytes_pool = Arc::new(BytesBufferPool::new(chunk_size, max_chunks * 2)); - + Self { config, concurrency_limiter, @@ -98,16 +98,16 @@ impl ParallelFileProcessor { bytes_pool, } } - + /// Creates a new processor instance with a buffer pool pub fn new_with_buffer_pool(config: AppConfig, buffer_pool: Arc) -> Self { let concurrency_limiter = Arc::new(Semaphore::new(config.concurrency.max_concurrent_io)); - + // Create BytesMut pool for efficient operations let chunk_size = config.resources.chunk_size_bytes; let max_chunks = config.concurrency.max_parallel_chunks; let bytes_pool = Arc::new(BytesBufferPool::new(chunk_size, max_chunks * 2)); - + Self { config, concurrency_limiter, @@ -115,34 +115,39 @@ impl ParallelFileProcessor { bytes_pool, } } - + /// Divides a file into chunks for parallel processing pub fn calculate_chunks(&self, file_size: u64) -> Vec { // Determine if the file needs parallel processing - let needs_parallel = self.config.resources.needs_parallel_processing( - file_size, &self.config.concurrency - ); - + let needs_parallel = self + .config + .resources + .needs_parallel_processing(file_size, &self.config.concurrency); + if !needs_parallel { // For small files, use a single chunk - return vec![ChunkRange { + return vec![ChunkRange { index: 0, start: 0, - size: file_size as usize + size: file_size as usize, }]; } - + // Calculate optimal number of chunks - let chunk_count = self.config.resources.calculate_optimal_chunks( - file_size, &self.config.concurrency - ); - + let chunk_count = self + .config + .resources + .calculate_optimal_chunks(file_size, &self.config.concurrency); + // Calculate size of each chunk - let chunk_size = self.config.resources.calculate_chunk_size(file_size, chunk_count); - + let chunk_size = self + .config + .resources + .calculate_chunk_size(file_size, chunk_count); + // Create chunk ranges let mut chunks = Vec::with_capacity(chunk_count); - + let mut start = 0; for i in 0..chunk_count { let current_chunk_size = if i == chunk_count - 1 { @@ -151,275 +156,328 @@ impl ParallelFileProcessor { } else { chunk_size }; - + chunks.push(ChunkRange { index: i, start, size: current_chunk_size, }); - + start += current_chunk_size as u64; } - - debug!("File size: {} bytes, divided into {} chunks of ~{} bytes each", - file_size, chunks.len(), chunk_size); - + + debug!( + "File size: {} bytes, divided into {} chunks of ~{} bytes each", + file_size, + chunks.len(), + chunk_size + ); + chunks } - + /// Reads a file in parallel and returns the complete content /// Optimized implementation using BytesMut to reduce memory copies - pub async fn read_file_parallel(&self, file_path: &PathBuf) -> Result, FileRepositoryError> { + pub async fn read_file_parallel( + &self, + file_path: &PathBuf, + ) -> Result, FileRepositoryError> { // Get file size - let metadata = tokio::fs::metadata(file_path).await + let metadata = tokio::fs::metadata(file_path) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - + let file_size = metadata.len(); - + // Check if the file is too large for memory if !self.config.resources.can_load_in_memory(file_size) { - return Err(FileRepositoryError::Other( - format!("File too large to load in memory: {} MB (max: {} MB)", - file_size / (1024 * 1024), - self.config.resources.max_in_memory_file_size_mb) - )); + return Err(FileRepositoryError::Other(format!( + "File too large to load in memory: {} MB (max: {} MB)", + file_size / (1024 * 1024), + self.config.resources.max_in_memory_file_size_mb + ))); } - + // Calculate chunks let chunks = self.calculate_chunks(file_size); - + if chunks.len() == 1 { // For a single chunk, use simple reading with buffer pool if available - info!("Reading file with size {}MB as a single chunk", file_size / (1024 * 1024)); - + info!( + "Reading file with size {}MB as a single chunk", + file_size / (1024 * 1024) + ); + if let Some(pool) = &self.buffer_pool { // Use buffer from the pool for efficient reading debug!("Using buffer pool for single chunk read"); let mut buffer = pool.get_buffer().await; - + // If the buffer is too small, revert to standard implementation if buffer.capacity() < file_size as usize { - debug!("Buffer from pool too small ({}), using standard read", buffer.capacity()); - let content = tokio::fs::read(file_path).await + debug!( + "Buffer from pool too small ({}), using standard read", + buffer.capacity() + ); + let content = tokio::fs::read(file_path) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - + return Ok(content); } - + // Use memory buffer from the pool - let mut file = File::open(file_path).await + let mut file = File::open(file_path) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - - let read_size = file.read(buffer.as_mut_slice()).await + + let read_size = file + .read(buffer.as_mut_slice()) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - + buffer.set_used(read_size); - + // Convert to Vec let content = buffer.into_vec(); return Ok(content); } else { // Standard implementation without pool - let content = tokio::fs::read(file_path).await + let content = tokio::fs::read(file_path) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - + return Ok(content); } } - + // For multiple chunks, use parallel reading - info!("Reading file with size {}MB in {} parallel chunks using BytesMut", - file_size / (1024 * 1024), chunks.len()); - + info!( + "Reading file with size {}MB in {} parallel chunks using BytesMut", + file_size / (1024 * 1024), + chunks.len() + ); + // Create final result buffer (pre-allocated) let mut result = BytesMut::with_capacity(file_size as usize); result.resize(file_size as usize, 0); let result_mutex = Arc::new(Mutex::new(result)); - + // Create tasks for each chunk let mut tasks = Vec::with_capacity(chunks.len()); - + // Open file once and share it - let file = Arc::new(File::open(file_path).await - .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?); - + let file = Arc::new( + File::open(file_path) + .await + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?, + ); + // Reference to BytesMut pool let bytes_pool = self.bytes_pool.clone(); - + // Process chunks in parallel for chunk in chunks { let file_clone = file.clone(); let result_clone = result_mutex.clone(); let semaphore_clone = self.concurrency_limiter.clone(); let bytes_pool_clone = bytes_pool.clone(); - + // Spawn task for this chunk - no need to copy the original data let task = task::spawn(async move { // Acquire semaphore permit let _permit = semaphore_clone.acquire().await.unwrap(); - + // Get a reusable buffer from the BytesMut pool let mut chunk_buffer = bytes_pool_clone.get_buffer().await; - + // Ensure it has sufficient capacity if chunk_buffer.capacity() < chunk.size { chunk_buffer = BytesMut::with_capacity(chunk.size); } // Resize to the exact size needed chunk_buffer.resize(chunk.size, 0); - + // Create a duplicate file descriptor for independent use let mut file_handle = file_clone.try_clone().await?; - + // Position and read directly into the BytesMut file_handle.seek(SeekFrom::Start(chunk.start)).await?; - let bytes_read = file_handle.read_exact(&mut chunk_buffer[..chunk.size]).await?; - + let bytes_read = file_handle + .read_exact(&mut chunk_buffer[..chunk.size]) + .await?; + if bytes_read != chunk.size { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, - format!("Expected to read {} bytes but got {}", chunk.size, bytes_read) + format!( + "Expected to read {} bytes but got {}", + chunk.size, bytes_read + ), )); } - + // Write to final result let mut result_lock = result_clone.lock().await; let start_pos = chunk.start as usize; let end_pos = start_pos + chunk.size; - + // Use copy_from_slice to copy from BytesMut to result buffer result_lock[start_pos..end_pos].copy_from_slice(&chunk_buffer[..chunk.size]); - + // Return the buffer to the pool for reuse bytes_pool_clone.return_buffer(chunk_buffer).await; - + // Log progress - debug!("Chunk {} processed: {} bytes from offset {}", - chunk.index, chunk.size, chunk.start); - + debug!( + "Chunk {} processed: {} bytes from offset {}", + chunk.index, chunk.size, chunk.start + ); + Ok::<_, io::Error>(()) }); - + tasks.push(task); } - + // Wait for all tasks to complete let results = join_all(tasks).await; - + // Check for errors for (i, task_result) in results.into_iter().enumerate() { match task_result { - Ok(Ok(())) => {}, + Ok(Ok(())) => {} Ok(Err(e)) => { error!("Error in chunk {}: {}", i, e); return Err(FileRepositoryError::StorageError(e.to_string())); - }, + } Err(e) => { error!("Task error in chunk {}: {}", i, e); return Err(FileRepositoryError::Other(format!("Task error: {}", e))); } } } - + // Get the final result and convert to Vec let result_buffer = result_mutex.lock().await; let result_vec = result_buffer.to_vec(); - - info!("Successfully read file of {}MB in parallel with optimized BytesMut", file_size / (1024 * 1024)); + + info!( + "Successfully read file of {}MB in parallel with optimized BytesMut", + file_size / (1024 * 1024) + ); Ok(result_vec) } - + /// Writes a file in parallel from a buffer /// Optimized implementation using BytesMut/Bytes to reduce memory copies pub async fn write_file_parallel( - &self, - file_path: &PathBuf, - content: &[u8] + &self, + file_path: &PathBuf, + content: &[u8], ) -> Result<(), FileRepositoryError> { let file_size = content.len() as u64; - + // Calculate chunks let chunks = self.calculate_chunks(file_size); - + if chunks.len() == 1 { // For a single chunk, use simple writing - info!("Writing file with size {}MB as a single chunk", file_size / (1024 * 1024)); - + info!( + "Writing file with size {}MB as a single chunk", + file_size / (1024 * 1024) + ); + // Standard implementation (buffer pooling offers no advantages for simple writing) - tokio::fs::write(file_path, content).await + tokio::fs::write(file_path, content) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - + return Ok(()); } - + // For multiple chunks, use parallel writing - info!("Writing file with size {}MB in {} parallel chunks using Bytes", - file_size / (1024 * 1024), chunks.len()); - + info!( + "Writing file with size {}MB in {} parallel chunks using Bytes", + file_size / (1024 * 1024), + chunks.len() + ); + // Create file (we don't use Mutex to reduce contention) - let file = File::create(file_path).await + let file = File::create(file_path) + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - + // Convert content to Bytes (single copy step) let content_bytes = Bytes::copy_from_slice(content); - + // Create tasks for each chunk let mut tasks = Vec::with_capacity(chunks.len()); - + // Process chunks in parallel for chunk in chunks { - let file_clone = file.try_clone().await + let file_clone = file + .try_clone() + .await .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; let semaphore_clone = self.concurrency_limiter.clone(); - + // Create Bytes slice (doesn't copy data, only references) let start_idx = chunk.start as usize; let end_idx = start_idx + chunk.size; let chunk_data = content_bytes.slice(start_idx..end_idx); - + // Create and launch task let task = task::spawn(async move { // Acquire semaphore permit let _permit = semaphore_clone.acquire().await.unwrap(); - + // Position and write let mut file_handle = file_clone; file_handle.seek(SeekFrom::Start(chunk.start)).await?; file_handle.write_all(&chunk_data).await?; - + // Log progress - debug!("Chunk {} written: {} bytes at offset {}", - chunk.index, chunk.size, chunk.start); - + debug!( + "Chunk {} written: {} bytes at offset {}", + chunk.index, chunk.size, chunk.start + ); + Ok::<_, io::Error>(()) }); - + tasks.push(task); } - + // Wait for all tasks to complete let results = join_all(tasks).await; - + // Check for errors for (i, task_result) in results.into_iter().enumerate() { match task_result { - Ok(Ok(())) => {}, + Ok(Ok(())) => {} Ok(Err(e)) => { error!("Error in chunk {}: {}", i, e); return Err(FileRepositoryError::StorageError(e.to_string())); - }, + } Err(e) => { error!("Task error in chunk {}: {}", i, e); return Err(FileRepositoryError::Other(format!("Task error: {}", e))); } } } - + // Ensure everything has been written correctly let mut file_handle = file; - file_handle.flush().await.map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; - - info!("Successfully wrote file of {}MB in parallel with optimized Bytes", file_size / (1024 * 1024)); + file_handle + .flush() + .await + .map_err(|e| FileRepositoryError::StorageError(e.to_string()))?; + + info!( + "Successfully wrote file of {}MB in parallel with optimized Bytes", + file_size / (1024 * 1024) + ); Ok(()) } } @@ -429,59 +487,62 @@ mod tests { use super::*; use bytes::BufMut; use tempfile::tempdir; - + #[tokio::test] async fn test_parallel_read_write() { // Create configuration with low threshold for testing let mut config = AppConfig::default(); config.concurrency.min_size_for_parallel_chunks_mb = 1; // 1MB for testing config.concurrency.max_parallel_chunks = 4; - + let processor = ParallelFileProcessor::new(config); - + // Create temporary directory let temp_dir = tempdir().unwrap(); let file_path = temp_dir.path().join("test_file.bin"); - + // Create test data (2MB) let size = 2 * 1024 * 1024; let mut test_data = Vec::with_capacity(size); for i in 0..size { test_data.push((i % 256) as u8); } - + // Write file in parallel - processor.write_file_parallel(&file_path, &test_data).await.unwrap(); - + processor + .write_file_parallel(&file_path, &test_data) + .await + .unwrap(); + // Read file in parallel let read_data = processor.read_file_parallel(&file_path).await.unwrap(); - + // Verify that the data is identical assert_eq!(test_data.len(), read_data.len()); assert_eq!(test_data, read_data); } - + #[tokio::test] async fn test_bytesmut_pool() { // Create pool let pool = BytesBufferPool::new(1024, 5); - + // Get buffer let mut buffer1 = pool.get_buffer().await; buffer1.put_slice(b"test data"); assert_eq!(&buffer1[..9], b"test data"); - + // Return buffer to the pool pool.return_buffer(buffer1).await; - + // Get another buffer (should be the same one) let buffer2 = pool.get_buffer().await; assert_eq!(buffer2.capacity(), 1024); - + // The buffer should be empty (cleared) assert_eq!(buffer2.len(), 0); } - + #[test] fn test_chunk_calculation() { // Create test configuration @@ -489,22 +550,22 @@ mod tests { config.concurrency.min_size_for_parallel_chunks_mb = 100; // 100MB config.concurrency.max_parallel_chunks = 4; config.concurrency.parallel_chunk_size_bytes = 50 * 1024 * 1024; // 50MB - + let processor = ParallelFileProcessor::new(config); - + // Small file (10MB) let small_file_size = 10 * 1024 * 1024; let chunks = processor.calculate_chunks(small_file_size); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].size as u64, small_file_size); - + // Large file (300MB) let large_file_size = 300 * 1024 * 1024; let chunks = processor.calculate_chunks(large_file_size); assert_eq!(chunks.len(), 4); // Limited to max_parallel_chunks - + // Verify that all chunks add up to the total size let total_size: u64 = chunks.iter().map(|c| c.size as u64).sum(); assert_eq!(total_size, large_file_size); } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/address_book_pg_repository.rs b/src/infrastructure/repositories/pg/address_book_pg_repository.rs index 23a18d3e..475e137d 100644 --- a/src/infrastructure/repositories/pg/address_book_pg_repository.rs +++ b/src/infrastructure/repositories/pg/address_book_pg_repository.rs @@ -3,9 +3,11 @@ use chrono::Utc; use sqlx::{PgPool, Row, types::Uuid}; use std::sync::Arc; -use crate::domain::entities::contact::AddressBook; -use crate::domain::repositories::address_book_repository::{AddressBookRepository, AddressBookRepositoryResult}; use crate::common::errors::DomainError; +use crate::domain::entities::contact::AddressBook; +use crate::domain::repositories::address_book_repository::{ + AddressBookRepository, AddressBookRepositoryResult, +}; pub struct AddressBookPgRepository { pool: Arc, @@ -19,7 +21,10 @@ impl AddressBookPgRepository { #[async_trait] impl AddressBookRepository for AddressBookPgRepository { - async fn create_address_book(&self, address_book: AddressBook) -> AddressBookRepositoryResult { + async fn create_address_book( + &self, + address_book: AddressBook, + ) -> AddressBookRepositoryResult { let row = sqlx::query( r#" INSERT INTO carddav.address_books (id, name, owner_id, description, color, is_public, created_at, updated_at) @@ -51,7 +56,10 @@ impl AddressBookRepository for AddressBookPgRepository { )) } - async fn update_address_book(&self, address_book: AddressBook) -> AddressBookRepositoryResult { + async fn update_address_book( + &self, + address_book: AddressBook, + ) -> AddressBookRepositoryResult { let now = Utc::now(); let row = sqlx::query( r#" @@ -59,7 +67,7 @@ impl AddressBookRepository for AddressBookPgRepository { SET name = $1, description = $2, color = $3, is_public = $4, updated_at = $5 WHERE id = $6 RETURNING id, name, owner_id, description, color, is_public, created_at, updated_at - "# + "#, ) .bind(address_book.name()) .bind(address_book.description()) @@ -69,7 +77,9 @@ impl AddressBookRepository for AddressBookPgRepository { .bind(address_book.id()) .fetch_one(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to update address book: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to update address book: {}", e)) + })?; Ok(AddressBook::from_raw( row.get("id"), @@ -88,59 +98,38 @@ impl AddressBookRepository for AddressBookPgRepository { r#" DELETE FROM carddav.address_books WHERE id = $1 - "# + "#, ) .bind(id) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to delete address book: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to delete address book: {}", e)) + })?; Ok(()) } - async fn get_address_book_by_id(&self, id: &Uuid) -> AddressBookRepositoryResult> { + async fn get_address_book_by_id( + &self, + id: &Uuid, + ) -> AddressBookRepositoryResult> { let maybe_row = sqlx::query( r#" SELECT id, name, owner_id, description, color, is_public, created_at, updated_at FROM carddav.address_books WHERE id = $1 - "# + "#, ) .bind(id) .fetch_optional(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get address book by id: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get address book by id: {}", e)) + })?; - let result = maybe_row.map(|row| AddressBook::from_raw( - row.get("id"), - row.get("name"), - row.get("owner_id"), - row.get("description"), - row.get("color"), - row.get("is_public"), - row.get("created_at"), - row.get("updated_at"), - )); - - Ok(result) - } - - async fn get_address_books_by_owner(&self, owner_id: &str) -> AddressBookRepositoryResult> { - let rows = sqlx::query( - r#" - SELECT id, name, owner_id, description, color, is_public, created_at, updated_at - FROM carddav.address_books - WHERE owner_id = $1 - ORDER BY name - "# - ) - .bind(owner_id) - .fetch_all(&*self.pool) - .await - .map_err(|e| DomainError::database_error(format!("Failed to get address books by owner: {}", e)))?; - - let result = rows.into_iter() - .map(|row| AddressBook::from_raw( + let result = maybe_row.map(|row| { + AddressBook::from_raw( row.get("id"), row.get("name"), row.get("owner_id"), @@ -149,13 +138,54 @@ impl AddressBookRepository for AddressBookPgRepository { row.get("is_public"), row.get("created_at"), row.get("updated_at"), - )) + ) + }); + + Ok(result) + } + + async fn get_address_books_by_owner( + &self, + owner_id: &str, + ) -> AddressBookRepositoryResult> { + let rows = sqlx::query( + r#" + SELECT id, name, owner_id, description, color, is_public, created_at, updated_at + FROM carddav.address_books + WHERE owner_id = $1 + ORDER BY name + "#, + ) + .bind(owner_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get address books by owner: {}", e)) + })?; + + let result = rows + .into_iter() + .map(|row| { + AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + ) + }) .collect(); Ok(result) } - async fn get_shared_address_books(&self, user_id: &str) -> AddressBookRepositoryResult> { + async fn get_shared_address_books( + &self, + user_id: &str, + ) -> AddressBookRepositoryResult> { let rows = sqlx::query( r#" SELECT a.id, a.name, a.owner_id, a.description, a.color, a.is_public, a.created_at, a.updated_at @@ -170,17 +200,20 @@ impl AddressBookRepository for AddressBookPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?; - let result = rows.into_iter() - .map(|row| AddressBook::from_raw( - row.get("id"), - row.get("name"), - row.get("owner_id"), - row.get("description"), - row.get("color"), - row.get("is_public"), - row.get("created_at"), - row.get("updated_at"), - )) + let result = rows + .into_iter() + .map(|row| { + AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + ) + }) .collect(); Ok(result) @@ -193,35 +226,45 @@ impl AddressBookRepository for AddressBookPgRepository { FROM carddav.address_books WHERE is_public = true ORDER BY name - "# + "#, ) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get public address books: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get public address books: {}", e)) + })?; - let result = rows.into_iter() - .map(|row| AddressBook::from_raw( - row.get("id"), - row.get("name"), - row.get("owner_id"), - row.get("description"), - row.get("color"), - row.get("is_public"), - row.get("created_at"), - row.get("updated_at"), - )) + let result = rows + .into_iter() + .map(|row| { + AddressBook::from_raw( + row.get("id"), + row.get("name"), + row.get("owner_id"), + row.get("description"), + row.get("color"), + row.get("is_public"), + row.get("created_at"), + row.get("updated_at"), + ) + }) .collect(); Ok(result) } - async fn share_address_book(&self, address_book_id: &Uuid, user_id: &str, can_write: bool) -> AddressBookRepositoryResult<()> { + async fn share_address_book( + &self, + address_book_id: &Uuid, + user_id: &str, + can_write: bool, + ) -> AddressBookRepositoryResult<()> { sqlx::query( r#" INSERT INTO carddav.address_book_shares (address_book_id, user_id, can_write) VALUES ($1, $2, $3) ON CONFLICT (address_book_id, user_id) DO UPDATE SET can_write = $3 - "# + "#, ) .bind(address_book_id) .bind(user_id) @@ -233,40 +276,52 @@ impl AddressBookRepository for AddressBookPgRepository { Ok(()) } - async fn unshare_address_book(&self, address_book_id: &Uuid, user_id: &str) -> AddressBookRepositoryResult<()> { + async fn unshare_address_book( + &self, + address_book_id: &Uuid, + user_id: &str, + ) -> AddressBookRepositoryResult<()> { sqlx::query( r#" DELETE FROM carddav.address_book_shares WHERE address_book_id = $1 AND user_id = $2 - "# + "#, ) .bind(address_book_id) .bind(user_id) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to unshare address book: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to unshare address book: {}", e)) + })?; Ok(()) } - async fn get_address_book_shares(&self, address_book_id: &Uuid) -> AddressBookRepositoryResult> { + async fn get_address_book_shares( + &self, + address_book_id: &Uuid, + ) -> AddressBookRepositoryResult> { let rows = sqlx::query( r#" SELECT user_id, can_write FROM carddav.address_book_shares WHERE address_book_id = $1 ORDER BY user_id - "# + "#, ) .bind(address_book_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get address book shares: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get address book shares: {}", e)) + })?; - let result = rows.into_iter() + let result = rows + .into_iter() .map(|row| (row.get("user_id"), row.get("can_write"))) .collect(); Ok(result) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs index 0865d442..6949d08e 100644 --- a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs @@ -3,9 +3,11 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row, types::Uuid}; use std::sync::Arc; -use crate::domain::entities::calendar_event::CalendarEvent; -use crate::domain::repositories::calendar_event_repository::{CalendarEventRepository, CalendarEventRepositoryResult}; use crate::common::errors::DomainError; +use crate::domain::entities::calendar_event::CalendarEvent; +use crate::domain::repositories::calendar_event_repository::{ + CalendarEventRepository, CalendarEventRepositoryResult, +}; pub struct CalendarEventPgRepository { pool: Arc, @@ -19,11 +21,14 @@ impl CalendarEventPgRepository { #[async_trait] impl CalendarEventRepository for CalendarEventPgRepository { - async fn create_event(&self, event: CalendarEvent) -> CalendarEventRepositoryResult { + async fn create_event( + &self, + event: CalendarEvent, + ) -> CalendarEventRepositoryResult { // This method would need a full implementation that builds the CalendarEvent // from the query result, using constructor methods // For this demonstration, we return the same event - + sqlx::query( r#" INSERT INTO caldav.calendar_events ( @@ -31,7 +36,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { all_day, rrule, created_at, updated_at, ical_uid, ical_data ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - "# + "#, ) .bind(event.id()) .bind(event.calendar_id()) @@ -48,15 +53,20 @@ impl CalendarEventRepository for CalendarEventPgRepository { .bind(event.ical_data()) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to create calendar event: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar event: {}", e)) + })?; // We return the same event instead of a result Ok(event) } - async fn update_event(&self, event: CalendarEvent) -> CalendarEventRepositoryResult { + async fn update_event( + &self, + event: CalendarEvent, + ) -> CalendarEventRepositoryResult { let now = Utc::now(); - + sqlx::query( r#" UPDATE caldav.calendar_events @@ -70,7 +80,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { ical_data = $8, updated_at = $9 WHERE id = $10 - "# + "#, ) .bind(event.summary()) .bind(event.description()) @@ -84,7 +94,9 @@ impl CalendarEventRepository for CalendarEventPgRepository { .bind(event.id()) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to update calendar event: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to update calendar event: {}", e)) + })?; // In a full implementation, we would retrieve the updated event // For simplicity, we return the same event we received @@ -96,21 +108,23 @@ impl CalendarEventRepository for CalendarEventPgRepository { r#" DELETE FROM caldav.calendar_events WHERE id = $1 - "# + "#, ) .bind(id) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to delete calendar event: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to delete calendar event: {}", e)) + })?; Ok(()) } async fn get_events_in_time_range( - &self, - calendar_id: &Uuid, - start: &DateTime, - end: &DateTime + &self, + calendar_id: &Uuid, + start: &DateTime, + end: &DateTime, ) -> CalendarEventRepositoryResult> { let rows = sqlx::query( r#" @@ -127,14 +141,16 @@ impl CalendarEventRepository for CalendarEventPgRepository { (rrule IS NOT NULL AND end_time >= $2) ) ORDER BY start_time - "# + "#, ) .bind(calendar_id) .bind(start) .bind(end) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get events in time range: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get events in time range: {}", e)) + })?; let mut events = Vec::new(); for row in rows { @@ -152,10 +168,13 @@ impl CalendarEventRepository for CalendarEventPgRepository { row.get("ical_data"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Error creating calendar event: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; events.push(event); } - + Ok(events) } @@ -168,18 +187,20 @@ impl CalendarEventRepository for CalendarEventPgRepository { created_at, updated_at, ical_uid, ical_data FROM caldav.calendar_events WHERE id = $1 - "# + "#, ) .bind(id) .fetch_optional(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get calendar event by id: {}", e)))? + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendar event by id: {}", e)) + })? .ok_or_else(|| DomainError::not_found("Calendar Event", id.to_string()))?; // In a real implementation, we would build a complete CalendarEvent object // For simplicity, we create an object with default values to // demonstrate the approach without macros - + let event = CalendarEvent::with_id( row.get("id"), row.get("calendar_id"), @@ -193,13 +214,19 @@ impl CalendarEventRepository for CalendarEventPgRepository { 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)))?; - + row.get("updated_at"), + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; + Ok(event) } - - async fn list_events_by_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult> { + + async fn list_events_by_calendar( + &self, + calendar_id: &Uuid, + ) -> CalendarEventRepositoryResult> { let rows = sqlx::query( r#" SELECT @@ -209,12 +236,14 @@ impl CalendarEventRepository for CalendarEventPgRepository { FROM caldav.calendar_events WHERE calendar_id = $1 ORDER BY start_time - "# + "#, ) .bind(calendar_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get events by calendar: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get events by calendar: {}", e)) + })?; let mut events = Vec::new(); for row in rows { @@ -232,16 +261,23 @@ impl CalendarEventRepository for CalendarEventPgRepository { row.get("ical_data"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Error creating calendar event: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; events.push(event); } - + Ok(events) } - - async fn find_events_by_summary(&self, calendar_id: &Uuid, summary: &str) -> CalendarEventRepositoryResult> { + + async fn find_events_by_summary( + &self, + calendar_id: &Uuid, + summary: &str, + ) -> CalendarEventRepositoryResult> { let search_pattern = format!("%{}%", summary); - + let rows = sqlx::query( r#" SELECT @@ -251,13 +287,15 @@ impl CalendarEventRepository for CalendarEventPgRepository { FROM caldav.calendar_events WHERE calendar_id = $1 AND summary ILIKE $2 ORDER BY start_time - "# + "#, ) .bind(calendar_id) .bind(&search_pattern) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to find events by summary: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to find events by summary: {}", e)) + })?; let mut events = Vec::new(); for row in rows { @@ -275,14 +313,21 @@ impl CalendarEventRepository for CalendarEventPgRepository { row.get("ical_data"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Error creating calendar event: {}", e)))?; + ) + .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> { + + async fn find_event_by_ical_uid( + &self, + calendar_id: &Uuid, + ical_uid: &str, + ) -> CalendarEventRepositoryResult> { let row_opt = sqlx::query( r#" SELECT @@ -291,13 +336,15 @@ impl CalendarEventRepository for CalendarEventPgRepository { created_at, updated_at, ical_uid, ical_data FROM caldav.calendar_events WHERE calendar_id = $1 AND ical_uid = $2 - "# + "#, ) .bind(calendar_id) .bind(ical_uid) .fetch_optional(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get calendar event by UID: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendar event by UID: {}", e)) + })?; match row_opt { Some(row) => { @@ -315,49 +362,62 @@ impl CalendarEventRepository for CalendarEventPgRepository { row.get("ical_data"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Error creating calendar event: {}", e)))?; + ) + .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 { + + async fn count_events_in_calendar( + &self, + calendar_id: &Uuid, + ) -> CalendarEventRepositoryResult { let row = sqlx::query( r#" SELECT COUNT(*) as count FROM caldav.calendar_events WHERE calendar_id = $1 - "# + "#, ) .bind(calendar_id) .fetch_one(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to count events in calendar: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to count events in calendar: {}", e)) + })?; Ok(row.get::("count")) } - - async fn delete_all_events_in_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult { + + async fn delete_all_events_in_calendar( + &self, + calendar_id: &Uuid, + ) -> CalendarEventRepositoryResult { let result = sqlx::query( r#" DELETE FROM caldav.calendar_events WHERE calendar_id = $1 - "# + "#, ) .bind(calendar_id) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to delete all events in calendar: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to delete all events in calendar: {}", e)) + })?; Ok(result.rows_affected() as i64) } - + async fn list_events_by_calendar_paginated( - &self, + &self, calendar_id: &Uuid, limit: i64, - offset: i64 + offset: i64, ) -> CalendarEventRepositoryResult> { let rows = sqlx::query( r#" @@ -369,14 +429,19 @@ impl CalendarEventRepository for CalendarEventPgRepository { WHERE calendar_id = $1 ORDER BY start_time LIMIT $2 OFFSET $3 - "# + "#, ) .bind(calendar_id) .bind(limit) .bind(offset) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get paginated events by calendar: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!( + "Failed to get paginated events by calendar: {}", + e + )) + })?; let mut events = Vec::new(); for row in rows { @@ -394,18 +459,21 @@ impl CalendarEventRepository for CalendarEventPgRepository { row.get("ical_data"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Error creating calendar event: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; events.push(event); } - + Ok(events) } - + async fn find_recurring_events_in_range( &self, calendar_id: &Uuid, start: &DateTime, - end: &DateTime + end: &DateTime, ) -> CalendarEventRepositoryResult> { let rows = sqlx::query( r#" @@ -419,14 +487,16 @@ impl CalendarEventRepository for CalendarEventPgRepository { AND end_time >= $2 AND start_time <= $3 ORDER BY start_time - "# + "#, ) .bind(calendar_id) .bind(start) .bind(end) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to find recurring events in range: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to find recurring events in range: {}", e)) + })?; let mut events = Vec::new(); for row in rows { @@ -444,10 +514,13 @@ impl CalendarEventRepository for CalendarEventPgRepository { row.get("ical_data"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Error creating calendar event: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; events.push(event); } - + Ok(events) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/calendar_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_pg_repository.rs index 3b9ec4bd..efb646c1 100644 --- a/src/infrastructure/repositories/pg/calendar_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_pg_repository.rs @@ -3,9 +3,11 @@ use chrono::Utc; use sqlx::{PgPool, Row, types::Uuid}; use std::sync::Arc; -use crate::domain::entities::calendar::Calendar; -use crate::domain::repositories::calendar_repository::{CalendarRepository, CalendarRepositoryResult}; use crate::common::errors::DomainError; +use crate::domain::entities::calendar::Calendar; +use crate::domain::repositories::calendar_repository::{ + CalendarRepository, CalendarRepositoryResult, +}; pub struct CalendarPgRepository { pool: Arc, @@ -38,7 +40,7 @@ impl CalendarRepository for CalendarPgRepository { .fetch_one(&*self.pool) .await .map_err(|e| DomainError::database_error(format!("Failed to create calendar: {}", e)))?; - + // Build the Calendar object using its with_id constructor let result = Calendar::with_id( row.get("id"), @@ -48,7 +50,10 @@ impl CalendarRepository for CalendarPgRepository { row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; Ok(result) } @@ -61,7 +66,7 @@ impl CalendarRepository for CalendarPgRepository { SET name = $1, description = $2, color = $3, is_public = $4, updated_at = $5 WHERE id = $6 RETURNING id, name, owner_id, description, color, is_public, created_at, updated_at - "# + "#, ) .bind(calendar.name()) .bind(calendar.description()) @@ -72,7 +77,7 @@ impl CalendarRepository for CalendarPgRepository { .fetch_one(&*self.pool) .await .map_err(|e| DomainError::database_error(format!("Failed to update calendar: {}", e)))?; - + // Build the Calendar object using its with_id constructor let result = Calendar::with_id( row.get("id"), @@ -82,7 +87,10 @@ impl CalendarRepository for CalendarPgRepository { row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; Ok(result) } @@ -92,7 +100,7 @@ impl CalendarRepository for CalendarPgRepository { r#" DELETE FROM caldav.calendars WHERE id = $1 - "# + "#, ) .bind(id) .execute(&*self.pool) @@ -108,7 +116,7 @@ impl CalendarRepository for CalendarPgRepository { SELECT id, name, owner_id, description, color, is_public, created_at, updated_at FROM caldav.calendars WHERE id = $1 - "# + "#, ) .bind(id) .fetch_optional(&*self.pool) @@ -124,24 +132,32 @@ impl CalendarRepository for CalendarPgRepository { row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; Ok(calendar) } - async fn list_calendars_by_owner(&self, owner_id: &str) -> CalendarRepositoryResult> { + async fn list_calendars_by_owner( + &self, + owner_id: &str, + ) -> CalendarRepositoryResult> { let rows = sqlx::query( r#" SELECT id, name, owner_id, description, color, is_public, created_at, updated_at FROM caldav.calendars WHERE owner_id = $1 ORDER BY name - "# + "#, ) .bind(owner_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get calendars by owner: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendars by owner: {}", e)) + })?; let mut calendars = Vec::new(); for row in rows { @@ -153,27 +169,38 @@ impl CalendarRepository for CalendarPgRepository { row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; calendars.push(calendar); } Ok(calendars) } - async fn find_calendar_by_name_and_owner(&self, name: &str, owner_id: &str) -> CalendarRepositoryResult { + async fn find_calendar_by_name_and_owner( + &self, + name: &str, + owner_id: &str, + ) -> CalendarRepositoryResult { let row = sqlx::query( r#" SELECT id, name, owner_id, description, color, is_public, created_at, updated_at FROM caldav.calendars WHERE name = $1 AND owner_id = $2 - "# + "#, ) .bind(name) .bind(owner_id) .fetch_optional(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to find calendar by name and owner: {}", e)))? - .ok_or_else(|| DomainError::not_found("Calendar", format!("{} (owned by {})", name, owner_id)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to find calendar by name and owner: {}", e)) + })? + .ok_or_else(|| { + DomainError::not_found("Calendar", format!("{} (owned by {})", name, owner_id)) + })?; let calendar = Calendar::with_id( row.get("id"), @@ -183,12 +210,18 @@ impl CalendarRepository for CalendarPgRepository { row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; Ok(calendar) } - async fn list_calendars_shared_with_user(&self, user_id: &str) -> CalendarRepositoryResult> { + async fn list_calendars_shared_with_user( + &self, + user_id: &str, + ) -> CalendarRepositoryResult> { let rows = sqlx::query( r#" SELECT c.id, c.name, c.owner_id, c.description, c.color, c.is_public, c.created_at, c.updated_at @@ -213,14 +246,21 @@ impl CalendarRepository for CalendarPgRepository { row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; calendars.push(calendar); } Ok(calendars) } - async fn list_public_calendars(&self, limit: i64, offset: i64) -> CalendarRepositoryResult> { + async fn list_public_calendars( + &self, + limit: i64, + offset: i64, + ) -> CalendarRepositoryResult> { let rows = sqlx::query( r#" SELECT id, name, owner_id, description, color, is_public, created_at, updated_at @@ -228,13 +268,15 @@ impl CalendarRepository for CalendarPgRepository { WHERE is_public = true ORDER BY name LIMIT $1 OFFSET $2 - "# + "#, ) .bind(limit) .bind(offset) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get public calendars: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get public calendars: {}", e)) + })?; let mut calendars = Vec::new(); for row in rows { @@ -243,17 +285,24 @@ impl CalendarRepository for CalendarPgRepository { row.get("name"), row.get("owner_id"), row.get("description"), - row.get("color"), + row.get("color"), row.get("created_at"), row.get("updated_at"), - ).map_err(|e| DomainError::database_error(format!("Failed to create calendar object: {}", e)))?; + ) + .map_err(|e| { + DomainError::database_error(format!("Failed to create calendar object: {}", e)) + })?; calendars.push(calendar); } Ok(calendars) } - async fn user_has_calendar_access(&self, calendar_id: &Uuid, user_id: &str) -> CalendarRepositoryResult { + async fn user_has_calendar_access( + &self, + calendar_id: &Uuid, + user_id: &str, + ) -> CalendarRepositoryResult { // Check if the user is the owner of the calendar or has a share let row = sqlx::query( r#" @@ -264,31 +313,39 @@ impl CalendarRepository for CalendarPgRepository { SELECT 1 FROM caldav.calendar_shares s WHERE s.calendar_id = $1 AND s.user_id = $2 ) as has_access - "# + "#, ) .bind(calendar_id) .bind(user_id) .fetch_one(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to check calendar access: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to check calendar access: {}", e)) + })?; Ok(row.get::("has_access")) } - async fn share_calendar(&self, calendar_id: &Uuid, user_id: &str, access_level: &str) -> CalendarRepositoryResult<()> { + async fn share_calendar( + &self, + calendar_id: &Uuid, + user_id: &str, + access_level: &str, + ) -> CalendarRepositoryResult<()> { // Validate access level if !["read", "write", "owner"].contains(&access_level) { - return Err(DomainError::validation_error( - format!("Invalid access level: '{}'. Must be 'read', 'write', or 'owner'", access_level) - )); + return Err(DomainError::validation_error(format!( + "Invalid access level: '{}'. Must be 'read', 'write', or 'owner'", + access_level + ))); } - + sqlx::query( r#" INSERT INTO caldav.calendar_shares (calendar_id, user_id, access_level) VALUES ($1, $2, $3) ON CONFLICT (calendar_id, user_id) DO UPDATE SET access_level = $3 - "# + "#, ) .bind(calendar_id) .bind(user_id) @@ -300,12 +357,16 @@ impl CalendarRepository for CalendarPgRepository { Ok(()) } - async fn remove_calendar_sharing(&self, calendar_id: &Uuid, user_id: &str) -> CalendarRepositoryResult<()> { + async fn remove_calendar_sharing( + &self, + calendar_id: &Uuid, + user_id: &str, + ) -> CalendarRepositoryResult<()> { sqlx::query( r#" DELETE FROM caldav.calendar_shares WHERE calendar_id = $1 AND user_id = $2 - "# + "#, ) .bind(calendar_id) .bind(user_id) @@ -316,19 +377,24 @@ impl CalendarRepository for CalendarPgRepository { Ok(()) } - async fn get_calendar_shares(&self, calendar_id: &Uuid) -> CalendarRepositoryResult> { + async fn get_calendar_shares( + &self, + calendar_id: &Uuid, + ) -> CalendarRepositoryResult> { let rows = sqlx::query( r#" SELECT user_id, access_level FROM caldav.calendar_shares WHERE calendar_id = $1 ORDER BY user_id - "# + "#, ) .bind(calendar_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get calendar shares: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendar shares: {}", e)) + })?; let mut shares = Vec::new(); for row in rows { @@ -337,76 +403,100 @@ impl CalendarRepository for CalendarPgRepository { Ok(shares) } - - async fn get_calendar_property(&self, calendar_id: &Uuid, property_name: &str) -> CalendarRepositoryResult> { + + async fn get_calendar_property( + &self, + calendar_id: &Uuid, + property_name: &str, + ) -> CalendarRepositoryResult> { let row = sqlx::query( r#" SELECT value FROM caldav.calendar_properties WHERE calendar_id = $1 AND name = $2 - "# + "#, ) .bind(calendar_id) .bind(property_name) .fetch_optional(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get calendar property: {}", e)))?; - + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendar property: {}", e)) + })?; + Ok(row.map(|r| r.get("value"))) } - - async fn set_calendar_property(&self, calendar_id: &Uuid, property_name: &str, property_value: &str) -> CalendarRepositoryResult<()> { + + async fn set_calendar_property( + &self, + calendar_id: &Uuid, + property_name: &str, + property_value: &str, + ) -> CalendarRepositoryResult<()> { sqlx::query( r#" INSERT INTO caldav.calendar_properties (calendar_id, name, value) VALUES ($1, $2, $3) ON CONFLICT (calendar_id, name) DO UPDATE SET value = $3 - "# + "#, ) .bind(calendar_id) .bind(property_name) .bind(property_value) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to set calendar property: {}", e)))?; - + .map_err(|e| { + DomainError::database_error(format!("Failed to set calendar property: {}", e)) + })?; + Ok(()) } - - async fn remove_calendar_property(&self, calendar_id: &Uuid, property_name: &str) -> CalendarRepositoryResult<()> { + + async fn remove_calendar_property( + &self, + calendar_id: &Uuid, + property_name: &str, + ) -> CalendarRepositoryResult<()> { sqlx::query( r#" DELETE FROM caldav.calendar_properties WHERE calendar_id = $1 AND name = $2 - "# + "#, ) .bind(calendar_id) .bind(property_name) .execute(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to remove calendar property: {}", e)))?; - + .map_err(|e| { + DomainError::database_error(format!("Failed to remove calendar property: {}", e)) + })?; + Ok(()) } - - async fn get_calendar_properties(&self, calendar_id: &Uuid) -> CalendarRepositoryResult> { + + async fn get_calendar_properties( + &self, + calendar_id: &Uuid, + ) -> CalendarRepositoryResult> { let rows = sqlx::query( r#" SELECT name, value FROM caldav.calendar_properties WHERE calendar_id = $1 - "# + "#, ) .bind(calendar_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get calendar properties: {}", e)))?; - + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendar properties: {}", e)) + })?; + let mut properties = std::collections::HashMap::new(); for row in rows { properties.insert(row.get("name"), row.get("value")); } - + Ok(properties) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/contact_group_pg_repository.rs b/src/infrastructure/repositories/pg/contact_group_pg_repository.rs index febdb14b..a9579e3a 100644 --- a/src/infrastructure/repositories/pg/contact_group_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_group_pg_repository.rs @@ -1,223 +1,276 @@ -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()) - } -} +use async_trait::async_trait; +use chrono::Utc; +use serde_json::Value as JsonValue; +use sqlx::{PgPool, Row, types::Uuid}; +use std::sync::Arc; + +use super::contact_persistence_dto::{ + AddressPersistenceDto, EmailPersistenceDto, PhonePersistenceDto, addresses_from_persistence, + emails_from_persistence, phones_from_persistence, +}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::contact::{Contact, ContactGroup}; +use crate::domain::repositories::contact_repository::{ + ContactGroupRepository, ContactRepositoryResult, +}; + +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_persistence_dto.rs b/src/infrastructure/repositories/pg/contact_persistence_dto.rs index 0ce545d3..7d4da567 100644 --- a/src/infrastructure/repositories/pg/contact_persistence_dto.rs +++ b/src/infrastructure/repositories/pg/contact_persistence_dto.rs @@ -1,129 +1,129 @@ -//! Persistence DTOs for Contact entities -//! -//! These DTOs are used for JSONB serialization/deserialization in PostgreSQL. -//! They mirror the domain entities but include serde traits required for persistence. -//! This keeps the domain layer free of infrastructure concerns (serde dependency). - -use serde::{Deserialize, Serialize}; -use crate::domain::entities::contact::{Email, Phone, Address}; - -/// Persistence DTO for Email - used for JSONB serialization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmailPersistenceDto { - pub email: String, - pub r#type: String, - pub is_primary: bool, -} - -impl From<&Email> for EmailPersistenceDto { - fn from(email: &Email) -> Self { - Self { - email: email.email.clone(), - r#type: email.r#type.clone(), - is_primary: email.is_primary, - } - } -} - -impl From for Email { - fn from(dto: EmailPersistenceDto) -> Self { - Self { - email: dto.email, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } -} - -/// Persistence DTO for Phone - used for JSONB serialization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhonePersistenceDto { - pub number: String, - pub r#type: String, - pub is_primary: bool, -} - -impl From<&Phone> for PhonePersistenceDto { - fn from(phone: &Phone) -> Self { - Self { - number: phone.number.clone(), - r#type: phone.r#type.clone(), - is_primary: phone.is_primary, - } - } -} - -impl From for Phone { - fn from(dto: PhonePersistenceDto) -> Self { - Self { - number: dto.number, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } -} - -/// Persistence DTO for Address - used for JSONB serialization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddressPersistenceDto { - pub street: Option, - pub city: Option, - pub state: Option, - pub postal_code: Option, - pub country: Option, - pub r#type: String, - pub is_primary: bool, -} - -impl From<&Address> for AddressPersistenceDto { - fn from(addr: &Address) -> Self { - Self { - street: addr.street.clone(), - city: addr.city.clone(), - state: addr.state.clone(), - postal_code: addr.postal_code.clone(), - country: addr.country.clone(), - r#type: addr.r#type.clone(), - is_primary: addr.is_primary, - } - } -} - -impl From for Address { - fn from(dto: AddressPersistenceDto) -> Self { - Self { - street: dto.street, - city: dto.city, - state: dto.state, - postal_code: dto.postal_code, - country: dto.country, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } -} - -/// Helper functions to convert collections -pub fn emails_to_persistence(emails: &[Email]) -> Vec { - emails.iter().map(EmailPersistenceDto::from).collect() -} - -pub fn emails_from_persistence(dtos: Vec) -> Vec { - dtos.into_iter().map(Email::from).collect() -} - -pub fn phones_to_persistence(phones: &[Phone]) -> Vec { - phones.iter().map(PhonePersistenceDto::from).collect() -} - -pub fn phones_from_persistence(dtos: Vec) -> Vec { - dtos.into_iter().map(Phone::from).collect() -} - -pub fn addresses_to_persistence(addresses: &[Address]) -> Vec { - addresses.iter().map(AddressPersistenceDto::from).collect() -} - -pub fn addresses_from_persistence(dtos: Vec) -> Vec
{ - dtos.into_iter().map(Address::from).collect() -} +//! Persistence DTOs for Contact entities +//! +//! These DTOs are used for JSONB serialization/deserialization in PostgreSQL. +//! They mirror the domain entities but include serde traits required for persistence. +//! This keeps the domain layer free of infrastructure concerns (serde dependency). + +use crate::domain::entities::contact::{Address, Email, Phone}; +use serde::{Deserialize, Serialize}; + +/// Persistence DTO for Email - used for JSONB serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailPersistenceDto { + pub email: String, + pub r#type: String, + pub is_primary: bool, +} + +impl From<&Email> for EmailPersistenceDto { + fn from(email: &Email) -> Self { + Self { + email: email.email.clone(), + r#type: email.r#type.clone(), + is_primary: email.is_primary, + } + } +} + +impl From for Email { + fn from(dto: EmailPersistenceDto) -> Self { + Self { + email: dto.email, + r#type: dto.r#type, + is_primary: dto.is_primary, + } + } +} + +/// Persistence DTO for Phone - used for JSONB serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhonePersistenceDto { + pub number: String, + pub r#type: String, + pub is_primary: bool, +} + +impl From<&Phone> for PhonePersistenceDto { + fn from(phone: &Phone) -> Self { + Self { + number: phone.number.clone(), + r#type: phone.r#type.clone(), + is_primary: phone.is_primary, + } + } +} + +impl From for Phone { + fn from(dto: PhonePersistenceDto) -> Self { + Self { + number: dto.number, + r#type: dto.r#type, + is_primary: dto.is_primary, + } + } +} + +/// Persistence DTO for Address - used for JSONB serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddressPersistenceDto { + pub street: Option, + pub city: Option, + pub state: Option, + pub postal_code: Option, + pub country: Option, + pub r#type: String, + pub is_primary: bool, +} + +impl From<&Address> for AddressPersistenceDto { + fn from(addr: &Address) -> Self { + Self { + street: addr.street.clone(), + city: addr.city.clone(), + state: addr.state.clone(), + postal_code: addr.postal_code.clone(), + country: addr.country.clone(), + r#type: addr.r#type.clone(), + is_primary: addr.is_primary, + } + } +} + +impl From for Address { + fn from(dto: AddressPersistenceDto) -> Self { + Self { + street: dto.street, + city: dto.city, + state: dto.state, + postal_code: dto.postal_code, + country: dto.country, + r#type: dto.r#type, + is_primary: dto.is_primary, + } + } +} + +/// Helper functions to convert collections +pub fn emails_to_persistence(emails: &[Email]) -> Vec { + emails.iter().map(EmailPersistenceDto::from).collect() +} + +pub fn emails_from_persistence(dtos: Vec) -> Vec { + dtos.into_iter().map(Email::from).collect() +} + +pub fn phones_to_persistence(phones: &[Phone]) -> Vec { + phones.iter().map(PhonePersistenceDto::from).collect() +} + +pub fn phones_from_persistence(dtos: Vec) -> Vec { + dtos.into_iter().map(Phone::from).collect() +} + +pub fn addresses_to_persistence(addresses: &[Address]) -> Vec { + addresses.iter().map(AddressPersistenceDto::from).collect() +} + +pub fn addresses_from_persistence(dtos: Vec) -> Vec
{ + dtos.into_iter().map(Address::from).collect() +} diff --git a/src/infrastructure/repositories/pg/contact_pg_repository.rs b/src/infrastructure/repositories/pg/contact_pg_repository.rs index 7041c844..bb2ed57a 100644 --- a/src/infrastructure/repositories/pg/contact_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_pg_repository.rs @@ -1,17 +1,17 @@ use async_trait::async_trait; use chrono::Utc; +use serde_json::Value as JsonValue; use sqlx::{PgPool, Row, types::Uuid}; use std::sync::Arc; -use serde_json::Value as JsonValue; +use super::contact_persistence_dto::{ + AddressPersistenceDto, EmailPersistenceDto, PhonePersistenceDto, addresses_from_persistence, + addresses_to_persistence, emails_from_persistence, emails_to_persistence, + phones_from_persistence, phones_to_persistence, +}; +use crate::common::errors::DomainError; 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, - emails_from_persistence, phones_from_persistence, addresses_from_persistence, - EmailPersistenceDto, PhonePersistenceDto, AddressPersistenceDto, -}; pub struct ContactPgRepository { pool: Arc, @@ -70,11 +70,11 @@ impl ContactRepository for ContactPgRepository { let email_dtos = emails_to_persistence(contact.email()); let phone_dtos = phones_to_persistence(contact.phone()); let address_dtos = addresses_to_persistence(contact.address()); - + let email_json = serde_json::to_value(&email_dtos).unwrap_or(JsonValue::Null); 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( r#" INSERT INTO carddav.contacts ( @@ -90,7 +90,7 @@ impl ContactRepository for ContactPgRepository { id, address_book_id, uid, full_name, first_name, last_name, nickname, email, phone, address, organization, title, notes, photo_url, birthday, anniversary, vcard, etag, created_at, updated_at - "# + "#, ) .bind(contact.id()) .bind(contact.address_book_id()) @@ -125,15 +125,15 @@ impl ContactRepository for ContactPgRepository { let email_dtos = emails_to_persistence(contact.email()); let phone_dtos = phones_to_persistence(contact.phone()); let address_dtos = addresses_to_persistence(contact.address()); - + let email_json = serde_json::to_value(&email_dtos).unwrap_or(JsonValue::Null); 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); - + // Create a clone of the contact with the updated timestamp let mut updated_contact = contact.clone(); updated_contact.set_updated_at(now); - + let row = sqlx::query( r#" UPDATE carddav.contacts @@ -159,7 +159,7 @@ impl ContactRepository for ContactPgRepository { id, address_book_id, uid, full_name, first_name, last_name, nickname, email, phone, address, organization, title, notes, photo_url, birthday, anniversary, vcard, etag, created_at, updated_at - "# + "#, ) .bind(updated_contact.full_name_owned()) .bind(updated_contact.first_name_owned()) @@ -190,7 +190,7 @@ impl ContactRepository for ContactPgRepository { r#" DELETE FROM carddav.contacts WHERE id = $1 - "# + "#, ) .bind(id) .execute(&*self.pool) @@ -209,7 +209,7 @@ impl ContactRepository for ContactPgRepository { birthday, anniversary, vcard, etag, created_at, updated_at FROM carddav.contacts WHERE id = $1 - "# + "#, ) .bind(id) .fetch_optional(&*self.pool) @@ -222,7 +222,11 @@ impl ContactRepository for ContactPgRepository { } } - async fn get_contact_by_uid(&self, address_book_id: &Uuid, uid: &str) -> ContactRepositoryResult> { + async fn get_contact_by_uid( + &self, + address_book_id: &Uuid, + uid: &str, + ) -> ContactRepositoryResult> { let row_opt = sqlx::query( r#" SELECT @@ -231,7 +235,7 @@ impl ContactRepository for ContactPgRepository { birthday, anniversary, vcard, etag, created_at, updated_at FROM carddav.contacts WHERE address_book_id = $1 AND uid = $2 - "# + "#, ) .bind(address_book_id) .bind(uid) @@ -245,7 +249,10 @@ impl ContactRepository for ContactPgRepository { } } - async fn get_contacts_by_address_book(&self, address_book_id: &Uuid) -> ContactRepositoryResult> { + async fn get_contacts_by_address_book( + &self, + address_book_id: &Uuid, + ) -> ContactRepositoryResult> { let rows = sqlx::query( r#" SELECT @@ -255,12 +262,14 @@ impl ContactRepository for ContactPgRepository { FROM carddav.contacts WHERE address_book_id = $1 ORDER BY full_name, first_name, last_name - "# + "#, ) .bind(address_book_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get contacts by address book: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get contacts by address book: {}", e)) + })?; let mut contacts = Vec::new(); for row in &rows { @@ -271,7 +280,7 @@ impl ContactRepository for ContactPgRepository { async fn get_contacts_by_email(&self, email: &str) -> ContactRepositoryResult> { let search_pattern = format!("%{}%", email); - + let rows = sqlx::query( r#" SELECT @@ -281,12 +290,14 @@ impl ContactRepository for ContactPgRepository { FROM carddav.contacts WHERE email::text ILIKE $1 ORDER BY full_name, first_name, last_name - "# + "#, ) .bind(&search_pattern) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get contacts by email: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get contacts by email: {}", e)) + })?; let mut contacts = Vec::new(); for row in &rows { @@ -295,7 +306,10 @@ impl ContactRepository for ContactPgRepository { Ok(contacts) } - async fn get_contacts_by_group(&self, group_id: &Uuid) -> ContactRepositoryResult> { + async fn get_contacts_by_group( + &self, + group_id: &Uuid, + ) -> ContactRepositoryResult> { let rows = sqlx::query( r#" SELECT @@ -306,12 +320,14 @@ impl ContactRepository for ContactPgRepository { INNER JOIN carddav.group_memberships m ON c.id = m.contact_id WHERE m.group_id = $1 ORDER BY c.full_name, c.first_name, c.last_name - "# + "#, ) .bind(group_id) .fetch_all(&*self.pool) .await - .map_err(|e| DomainError::database_error(format!("Failed to get contacts by group: {}", e)))?; + .map_err(|e| { + DomainError::database_error(format!("Failed to get contacts by group: {}", e)) + })?; let mut contacts = Vec::new(); for row in &rows { @@ -320,9 +336,13 @@ impl ContactRepository for ContactPgRepository { Ok(contacts) } - async fn search_contacts(&self, address_book_id: &Uuid, query: &str) -> ContactRepositoryResult> { + async fn search_contacts( + &self, + address_book_id: &Uuid, + query: &str, + ) -> ContactRepositoryResult> { let search_pattern = format!("%{}%", query); - + let rows = sqlx::query( r#" SELECT @@ -341,7 +361,7 @@ impl ContactRepository for ContactPgRepository { OR organization ILIKE $2 ) ORDER BY full_name, first_name, last_name - "# + "#, ) .bind(address_book_id) .bind(&search_pattern) @@ -355,4 +375,4 @@ impl ContactRepository for ContactPgRepository { } Ok(contacts) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index a10151f2..ac71ce04 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -1,12 +1,12 @@ -use std::sync::Arc; use async_trait::async_trait; use sqlx::{PgPool, Row}; +use std::sync::Arc; use tracing::error; use uuid::Uuid; use crate::application::dtos::favorites_dto::FavoriteItemDto; use crate::application::ports::favorites_ports::FavoritesRepositoryPort; -use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::common::errors::{DomainError, ErrorKind, Result}; /// PostgreSQL implementation of the favorites persistence port. pub struct FavoritesPgRepository { @@ -42,7 +42,11 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { .await .map_err(|e| { error!("Database error fetching favorites: {}", e); - DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to fetch favorites: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to fetch favorites: {}", e), + ) })?; let favorites = rows @@ -76,7 +80,11 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { .await .map_err(|e| { error!("Database error adding favorite: {}", e); - DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to add to favorites: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to add to favorites: {}", e), + ) })?; Ok(()) @@ -98,7 +106,11 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { .await .map_err(|e| { error!("Database error removing favorite: {}", e); - DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to remove from favorites: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to remove from favorites: {}", e), + ) })?; Ok(result.rows_affected() > 0) @@ -122,7 +134,11 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { .await .map_err(|e| { error!("Database error checking favorite status: {}", e); - DomainError::new(ErrorKind::InternalError, "Favorites", format!("Failed to check favorite status: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to check favorite status: {}", e), + ) })?; Ok(row.try_get("is_favorite").unwrap_or(false)) diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index e2b14931..72c62ec1 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -1,9 +1,9 @@ mod address_book_pg_repository; -mod calendar_pg_repository; mod calendar_event_pg_repository; -mod contact_pg_repository; +mod calendar_pg_repository; mod contact_group_pg_repository; mod contact_persistence_dto; +mod contact_pg_repository; mod favorites_pg_repository; mod recent_items_pg_repository; mod session_pg_repository; @@ -12,11 +12,11 @@ mod transaction_utils; mod user_pg_repository; 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 calendar_pg_repository::CalendarPgRepository; pub use contact_group_pg_repository::ContactGroupPgRepository; pub use contact_persistence_dto::*; +pub use contact_pg_repository::ContactPgRepository; pub use favorites_pg_repository::FavoritesPgRepository; pub use recent_items_pg_repository::RecentItemsPgRepository; pub use session_pg_repository::SessionPgRepository; diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 078f7db1..9d59a90c 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -1,12 +1,12 @@ -use std::sync::Arc; use async_trait::async_trait; use sqlx::{PgPool, Row}; +use std::sync::Arc; use tracing::error; use uuid::Uuid; use crate::application::dtos::recent_dto::RecentItemDto; use crate::application::ports::recent_ports::RecentItemsRepositoryPort; -use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::common::errors::{DomainError, ErrorKind, Result}; /// PostgreSQL implementation of the recent items persistence port. pub struct RecentItemsPgRepository { @@ -44,7 +44,11 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { .await .map_err(|e| { error!("Database error fetching recent items: {}", e); - DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to fetch recent items: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to fetch recent items: {}", e), + ) })?; let items = rows @@ -79,7 +83,11 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { .await .map_err(|e| { error!("Database error upserting recent item access: {}", e); - DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to record item access: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to record item access: {}", e), + ) })?; Ok(()) @@ -101,7 +109,11 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { .await .map_err(|e| { error!("Database error removing recent item: {}", e); - DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to remove recent item: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to remove recent item: {}", e), + ) })?; Ok(result.rows_affected() > 0) @@ -121,7 +133,11 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { .await .map_err(|e| { error!("Database error clearing recent items: {}", e); - DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to clear recent items: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to clear recent items: {}", e), + ) })?; Ok(()) @@ -147,7 +163,11 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { .await .map_err(|e| { error!("Database error pruning old recent items: {}", e); - DomainError::new(ErrorKind::InternalError, "RecentItems", format!("Failed to prune recent items: {}", e)) + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to prune recent items: {}", e), + ) })?; Ok(()) diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 6e6bffd8..7155d093 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -1,13 +1,15 @@ use async_trait::async_trait; -use sqlx::{PgPool, Row}; -use std::sync::Arc; use chrono::Utc; use futures::future::BoxFuture; +use sqlx::{PgPool, Row}; +use std::sync::Arc; -use crate::domain::entities::session::Session; -use crate::domain::repositories::session_repository::{SessionRepository, SessionRepositoryError, SessionRepositoryResult}; use crate::application::ports::auth_ports::SessionStoragePort; use crate::common::errors::DomainError; +use crate::domain::entities::session::Session; +use crate::domain::repositories::session_repository::{ + SessionRepository, SessionRepositoryError, SessionRepositoryResult, +}; use crate::infrastructure::repositories::pg::transaction_utils::with_transaction; // Implement From for SessionRepositoryError to allow automatic conversions @@ -25,16 +27,14 @@ impl SessionPgRepository { pub fn new(pool: Arc) -> Self { Self { pool } } - + // Helper method to map SQL errors to domain errors pub fn map_sqlx_error(err: sqlx::Error) -> SessionRepositoryError { match err { sqlx::Error::RowNotFound => { SessionRepositoryError::NotFound("Session not found".to_string()) - }, - _ => SessionRepositoryError::DatabaseError( - format!("Database error: {}", err) - ), + } + _ => SessionRepositoryError::DatabaseError(format!("Database error: {}", err)), } } } @@ -45,65 +45,66 @@ impl SessionRepository for SessionPgRepository { async fn create_session(&self, session: Session) -> SessionRepositoryResult { // Create a copy of the session for the closure let session_clone = session.clone(); - - with_transaction( - &self.pool, - "create_session", - |tx| { - Box::pin(async move { - // Insert the session - sqlx::query( - r#" + + with_transaction(&self.pool, "create_session", |tx| { + Box::pin(async move { + // Insert the session + sqlx::query( + r#" INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 ) - "# - ) - .bind(session_clone.id()) - .bind(session_clone.user_id()) - .bind(session_clone.refresh_token()) - .bind(session_clone.expires_at()) - .bind(session_clone.ip_address()) - .bind(session_clone.user_agent()) - .bind(session_clone.created_at()) - .bind(session_clone.is_revoked()) - .execute(&mut **tx) - .await - .map_err(Self::map_sqlx_error)?; - - // Optionally, update the user's last login - // within the same transaction - sqlx::query( - r#" + "#, + ) + .bind(session_clone.id()) + .bind(session_clone.user_id()) + .bind(session_clone.refresh_token()) + .bind(session_clone.expires_at()) + .bind(session_clone.ip_address()) + .bind(session_clone.user_agent()) + .bind(session_clone.created_at()) + .bind(session_clone.is_revoked()) + .execute(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + // Optionally, update the user's last login + // within the same transaction + sqlx::query( + r#" UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1 - "# - ) - .bind(session_clone.user_id()) - .execute(&mut **tx) - .await - .map_err(|e| { - // Convert the error but without interrupting session - // creation if the update fails - tracing::warn!("Could not update last_login_at for user {}: {}", - session_clone.user_id(), e); - SessionRepositoryError::DatabaseError(format!( - "Session created but could not update last_login_at: {}", e - )) - })?; - - Ok(session_clone) - }) as BoxFuture<'_, SessionRepositoryResult> - } - ).await?; - + "#, + ) + .bind(session_clone.user_id()) + .execute(&mut **tx) + .await + .map_err(|e| { + // Convert the error but without interrupting session + // creation if the update fails + tracing::warn!( + "Could not update last_login_at for user {}: {}", + session_clone.user_id(), + e + ); + SessionRepositoryError::DatabaseError(format!( + "Session created but could not update last_login_at: {}", + e + )) + })?; + + Ok(session_clone) + }) as BoxFuture<'_, SessionRepositoryResult> + }) + .await?; + Ok(session) } - + /// Gets a session by ID async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult { let row = sqlx::query( @@ -113,7 +114,7 @@ impl SessionRepository for SessionPgRepository { ip_address, user_agent, created_at, revoked FROM auth.sessions WHERE id = $1 - "# + "#, ) .bind(id) .fetch_one(&*self.pool) @@ -131,9 +132,12 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), )) } - + /// Gets a session by refresh token - async fn get_session_by_refresh_token(&self, refresh_token: &str) -> SessionRepositoryResult { + async fn get_session_by_refresh_token( + &self, + refresh_token: &str, + ) -> SessionRepositoryResult { let row = sqlx::query( r#" SELECT @@ -141,7 +145,7 @@ impl SessionRepository for SessionPgRepository { ip_address, user_agent, created_at, revoked FROM auth.sessions WHERE refresh_token = $1 - "# + "#, ) .bind(refresh_token) .fetch_one(&*self.pool) @@ -159,9 +163,12 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), )) } - + /// Gets all sessions for a user - async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult> { + async fn get_sessions_by_user_id( + &self, + user_id: &str, + ) -> SessionRepositoryResult> { let rows = sqlx::query( r#" SELECT @@ -170,14 +177,15 @@ impl SessionRepository for SessionPgRepository { FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC - "# + "#, ) .bind(user_id) .fetch_all(&*self.pool) .await .map_err(Self::map_sqlx_error)?; - let sessions = rows.into_iter() + let sessions = rows + .into_iter() .map(|row| { Session::from_raw( row.get("id"), @@ -194,90 +202,84 @@ impl SessionRepository for SessionPgRepository { Ok(sessions) } - + /// Revokes a specific session using a transaction async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()> { let id = session_id.to_string(); // Clone for use in closure - - with_transaction( - &self.pool, - "revoke_session", - |tx| { - Box::pin(async move { - // Revoke the session - let result = sqlx::query( - r#" + + with_transaction(&self.pool, "revoke_session", |tx| { + Box::pin(async move { + // Revoke the session + let result = sqlx::query( + r#" UPDATE auth.sessions SET revoked = true WHERE id = $1 RETURNING user_id - "# - ) - .bind(&id) - .fetch_optional(&mut **tx) - .await - .map_err(Self::map_sqlx_error)?; - - // If we found the session, we can log a security event - if let Some(row) = result { - let user_id: String = row.try_get("user_id").unwrap_or_default(); - - // Log security event (in a security table) - // This is optional but shows how additional operations - // can be performed in the same transaction - tracing::info!("Session with ID {} for user {} revoked", id, user_id); - } - - Ok(()) - }) as BoxFuture<'_, SessionRepositoryResult<()>> - } - ).await + "#, + ) + .bind(&id) + .fetch_optional(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + // If we found the session, we can log a security event + if let Some(row) = result { + let user_id: String = row.try_get("user_id").unwrap_or_default(); + + // Log security event (in a security table) + // This is optional but shows how additional operations + // can be performed in the same transaction + tracing::info!("Session with ID {} for user {} revoked", id, user_id); + } + + Ok(()) + }) as BoxFuture<'_, SessionRepositoryResult<()>> + }) + .await } - + /// Revokes all sessions for a user using a transaction async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult { let user_id_clone = user_id.to_string(); // Clone for use in closure - - with_transaction( - &self.pool, - "revoke_all_user_sessions", - |tx| { - Box::pin(async move { - // Revoke all sessions for the user - let result = sqlx::query( - r#" + + with_transaction(&self.pool, "revoke_all_user_sessions", |tx| { + Box::pin(async move { + // Revoke all sessions for the user + let result = sqlx::query( + r#" UPDATE auth.sessions SET revoked = true WHERE user_id = $1 AND revoked = false - "# - ) - .bind(&user_id_clone) - .execute(&mut **tx) - .await - .map_err(Self::map_sqlx_error)?; - - let affected = result.rows_affected(); - - // Log security event - if affected > 0 { - tracing::info!("Revoked {} sessions for user {}", affected, user_id_clone); - } - - Ok(affected) - }) as BoxFuture<'_, SessionRepositoryResult> - } - ).await + "#, + ) + .bind(&user_id_clone) + .execute(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + let affected = result.rows_affected(); + + // Log security event + if affected > 0 { + tracing::info!("Revoked {} sessions for user {}", affected, user_id_clone); + } + + Ok(affected) + }) as BoxFuture<'_, SessionRepositoryResult> + }) + .await } - + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult { let now = Utc::now(); - + let result = sqlx::query( r#" DELETE FROM auth.sessions WHERE expires_at < $1 - "# + "#, ) .bind(now) .execute(&*self.pool) @@ -292,22 +294,29 @@ impl SessionRepository for SessionPgRepository { #[async_trait] impl SessionStoragePort for SessionPgRepository { async fn create_session(&self, session: Session) -> Result { - SessionRepository::create_session(self, session).await.map_err(DomainError::from) + SessionRepository::create_session(self, session) + .await + .map_err(DomainError::from) } - - async fn get_session_by_refresh_token(&self, refresh_token: &str) -> Result { + + async fn get_session_by_refresh_token( + &self, + refresh_token: &str, + ) -> Result { SessionRepository::get_session_by_refresh_token(self, refresh_token) .await .map_err(DomainError::from) } - + async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError> { - SessionRepository::revoke_session(self, session_id).await.map_err(DomainError::from) + SessionRepository::revoke_session(self, session_id) + .await + .map_err(DomainError::from) } - + async fn revoke_all_user_sessions(&self, user_id: &str) -> Result { SessionRepository::revoke_all_user_sessions(self, user_id) .await .map_err(DomainError::from) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/settings_pg_repository.rs b/src/infrastructure/repositories/pg/settings_pg_repository.rs index 3aba433e..aaa5ea38 100644 --- a/src/infrastructure/repositories/pg/settings_pg_repository.rs +++ b/src/infrastructure/repositories/pg/settings_pg_repository.rs @@ -1,88 +1,102 @@ -use std::collections::HashMap; -use std::sync::Arc; -use async_trait::async_trait; -use sqlx::PgPool; - -use crate::domain::repositories::settings_repository::SettingsRepository; -use crate::common::errors::{DomainError, ErrorKind}; - -pub struct SettingsPgRepository { - pool: Arc, -} - -impl SettingsPgRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } -} - -#[async_trait] -impl SettingsRepository for SettingsPgRepository { - async fn get(&self, key: &str) -> Result, DomainError> { - let row = sqlx::query_scalar::<_, String>( - "SELECT value FROM auth.admin_settings WHERE key = $1" - ) - .bind(key) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "Settings", format!("DB error: {}", e), - ))?; - - Ok(row) - } - - async fn get_by_category(&self, category: &str) -> Result, DomainError> { - let rows = sqlx::query_as::<_, (String, String)>( - "SELECT key, value FROM auth.admin_settings WHERE category = $1" - ) - .bind(category) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "Settings", format!("DB error: {}", e), - ))?; - - Ok(rows.into_iter().collect()) - } - - async fn set( - &self, - key: &str, - value: &str, - category: &str, - is_secret: bool, - updated_by: Option<&str>, - ) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (key) DO UPDATE - SET value = $2, category = $3, is_secret = $4, updated_by = $5, updated_at = NOW()" - ) - .bind(key) - .bind(value) - .bind(category) - .bind(is_secret) - .bind(updated_by) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "Settings", format!("DB error: {}", e), - ))?; - - Ok(()) - } - - async fn delete(&self, key: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM auth.admin_settings WHERE key = $1") - .bind(key) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "Settings", format!("DB error: {}", e), - ))?; - - Ok(()) - } -} +use async_trait::async_trait; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::repositories::settings_repository::SettingsRepository; + +pub struct SettingsPgRepository { + pool: Arc, +} + +impl SettingsPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl SettingsRepository for SettingsPgRepository { + async fn get(&self, key: &str) -> Result, DomainError> { + let row = + sqlx::query_scalar::<_, String>("SELECT value FROM auth.admin_settings WHERE key = $1") + .bind(key) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Settings", + format!("DB error: {}", e), + ) + })?; + + Ok(row) + } + + async fn get_by_category( + &self, + category: &str, + ) -> Result, DomainError> { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT key, value FROM auth.admin_settings WHERE category = $1", + ) + .bind(category) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Settings", + format!("DB error: {}", e), + ) + })?; + + Ok(rows.into_iter().collect()) + } + + async fn set( + &self, + key: &str, + value: &str, + category: &str, + is_secret: bool, + updated_by: Option<&str>, + ) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = $2, category = $3, is_secret = $4, updated_by = $5, updated_at = NOW()" + ) + .bind(key) + .bind(value) + .bind(category) + .bind(is_secret) + .bind(updated_by) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "Settings", format!("DB error: {}", e), + ))?; + + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM auth.admin_settings WHERE key = $1") + .bind(key) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Settings", + format!("DB error: {}", e), + ) + })?; + + Ok(()) + } +} diff --git a/src/infrastructure/repositories/pg/transaction_utils.rs b/src/infrastructure/repositories/pg/transaction_utils.rs index 63e33bd4..bfc5ee82 100644 --- a/src/infrastructure/repositories/pg/transaction_utils.rs +++ b/src/infrastructure/repositories/pg/transaction_utils.rs @@ -1,4 +1,4 @@ -use sqlx::{PgPool, Transaction, Postgres, Error as SqlxError}; +use sqlx::{Error as SqlxError, PgPool, Postgres, Transaction}; use std::sync::Arc; use tracing::{debug, error, info}; @@ -13,17 +13,19 @@ pub async fn with_transaction( operation: F, ) -> Result where - F: for<'c> FnOnce(&'c mut Transaction<'_, Postgres>) -> futures::future::BoxFuture<'c, Result>, + F: for<'c> FnOnce( + &'c mut Transaction<'_, Postgres>, + ) -> futures::future::BoxFuture<'c, Result>, E: From + std::fmt::Display, { debug!("Starting database transaction for: {}", operation_name); - + // Begin transaction let mut tx = pool.begin().await.map_err(|e| { error!("Failed to begin transaction for {}: {}", operation_name, e); E::from(e) })?; - + // Execute the operation within the transaction match operation(&mut tx).await { Ok(result) => { @@ -32,17 +34,20 @@ where Ok(_) => { debug!("Transaction committed successfully for: {}", operation_name); Ok(result) - }, + } Err(e) => { error!("Failed to commit transaction for {}: {}", operation_name, e); Err(E::from(e)) } } - }, + } Err(e) => { // If operation fails, rollback the transaction if let Err(rollback_err) = tx.rollback().await { - error!("Failed to rollback transaction for {}: {}", operation_name, rollback_err); + error!( + "Failed to rollback transaction for {}: {}", + operation_name, rollback_err + ); // Still return the original error } else { info!("Transaction rolled back for {}: {}", operation_name, e); @@ -50,4 +55,4 @@ where Err(e) } } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index e7492734..bab4c2ff 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -1,12 +1,14 @@ use async_trait::async_trait; +use futures::future::BoxFuture; use sqlx::{PgPool, Row}; use std::sync::Arc; -use futures::future::BoxFuture; -use crate::domain::entities::user::{User, UserRole}; -use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError, UserRepositoryResult, StorageStats}; use crate::application::ports::auth_ports::UserStoragePort; use crate::common::errors::DomainError; +use crate::domain::entities::user::{User, UserRole}; +use crate::domain::repositories::user_repository::{ + StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult, +}; use crate::infrastructure::repositories::pg::transaction_utils::with_transaction; // Implement From for UserRepositoryError to allow automatic conversions @@ -24,28 +26,20 @@ impl UserPgRepository { pub fn new(pool: Arc) -> Self { Self { pool } } - + // Helper method to map SQL errors to domain errors pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError { match err { - sqlx::Error::RowNotFound => { - UserRepositoryError::NotFound("User not found".to_string()) - }, + sqlx::Error::RowNotFound => UserRepositoryError::NotFound("User not found".to_string()), sqlx::Error::Database(db_err) => { if db_err.code().is_some_and(|code| code == "23505") { // PostgreSQL uniqueness violation code - UserRepositoryError::AlreadyExists( - "User or email already exists".to_string() - ) + UserRepositoryError::AlreadyExists("User or email already exists".to_string()) } else { - UserRepositoryError::DatabaseError( - format!("Database error: {}", db_err) - ) + UserRepositoryError::DatabaseError(format!("Database error: {}", db_err)) } - }, - _ => UserRepositoryError::DatabaseError( - format!("Database error: {}", err) - ), + } + _ => UserRepositoryError::DatabaseError(format!("Database error: {}", err)), } } } @@ -56,21 +50,18 @@ impl UserRepository for UserPgRepository { async fn create_user(&self, user: User) -> UserRepositoryResult { // Create a copy of the user for the closure let user_clone = user.clone(); - - with_transaction( - &self.pool, - "create_user", - |tx| { - // We need to move the closure into a BoxFuture to return inside - // the with_transaction call - Box::pin(async move { - // Use getters to extract the values - // Convert user.role() to string to pass it as plain text - let role_str = user_clone.role().to_string(); - - // Modify the SQL to do an explicit cast to the auth.userrole type - let _result = sqlx::query( - r#" + + with_transaction(&self.pool, "create_user", |tx| { + // We need to move the closure into a BoxFuture to return inside + // the with_transaction call + Box::pin(async move { + // Use getters to extract the values + // Convert user.role() to string to pass it as plain text + let role_str = user_clone.role().to_string(); + + // Modify the SQL to do an explicit cast to the auth.userrole type + let _result = sqlx::query( + r#" INSERT INTO auth.users ( id, username, email, password_hash, role, storage_quota_bytes, storage_used_bytes, @@ -81,36 +72,36 @@ impl UserRepository for UserPgRepository { $12, $13 ) RETURNING * - "# - ) - .bind(user_clone.id()) - .bind(user_clone.username()) - .bind(user_clone.email()) - .bind(user_clone.password_hash()) - .bind(&role_str) // Convert to string but with explicit cast in SQL - .bind(user_clone.storage_quota_bytes()) - .bind(user_clone.storage_used_bytes()) - .bind(user_clone.created_at()) - .bind(user_clone.updated_at()) - .bind(user_clone.last_login_at()) - .bind(user_clone.is_active()) - .bind(user_clone.oidc_provider()) - .bind(user_clone.oidc_subject()) - .execute(&mut **tx) - .await - .map_err(Self::map_sqlx_error)?; - - // We could perform additional operations here, - // such as configuring permissions, roles, etc. - - Ok(user_clone) - }) as BoxFuture<'_, UserRepositoryResult> - } - ).await?; - + "#, + ) + .bind(user_clone.id()) + .bind(user_clone.username()) + .bind(user_clone.email()) + .bind(user_clone.password_hash()) + .bind(&role_str) // Convert to string but with explicit cast in SQL + .bind(user_clone.storage_quota_bytes()) + .bind(user_clone.storage_used_bytes()) + .bind(user_clone.created_at()) + .bind(user_clone.updated_at()) + .bind(user_clone.last_login_at()) + .bind(user_clone.is_active()) + .bind(user_clone.oidc_provider()) + .bind(user_clone.oidc_subject()) + .execute(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + // We could perform additional operations here, + // such as configuring permissions, roles, etc. + + Ok(user_clone) + }) as BoxFuture<'_, UserRepositoryResult> + }) + .await?; + Ok(user) // Return the original user for simplicity } - + /// Gets a user by ID async fn get_user_by_id(&self, id: &str) -> UserRepositoryResult { let row = sqlx::query( @@ -122,7 +113,7 @@ impl UserRepository for UserPgRepository { oidc_provider, oidc_subject FROM auth.users WHERE id = $1 - "# + "#, ) .bind(id) .fetch_one(&*self.pool) @@ -135,7 +126,7 @@ impl UserRepository for UserPgRepository { Some("admin") => UserRole::Admin, _ => UserRole::User, }; - + Ok(User::from_data_full( row.get("id"), row.get("username"), @@ -152,7 +143,7 @@ impl UserRepository for UserPgRepository { row.get("oidc_subject"), )) } - + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult { let row = sqlx::query( @@ -164,7 +155,7 @@ impl UserRepository for UserPgRepository { oidc_provider, oidc_subject FROM auth.users WHERE username = $1 - "# + "#, ) .bind(username) .fetch_one(&*self.pool) @@ -177,7 +168,7 @@ impl UserRepository for UserPgRepository { Some("admin") => UserRole::Admin, _ => UserRole::User, }; - + Ok(User::from_data_full( row.get("id"), row.get("username"), @@ -194,7 +185,7 @@ impl UserRepository for UserPgRepository { row.get("oidc_subject"), )) } - + /// Gets a user by email async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult { let row = sqlx::query( @@ -206,7 +197,7 @@ impl UserRepository for UserPgRepository { oidc_provider, oidc_subject FROM auth.users WHERE email = $1 - "# + "#, ) .bind(email) .fetch_one(&*self.pool) @@ -219,7 +210,7 @@ impl UserRepository for UserPgRepository { Some("admin") => UserRole::Admin, _ => UserRole::User, }; - + Ok(User::from_data_full( row.get("id"), row.get("username"), @@ -236,20 +227,17 @@ impl UserRepository for UserPgRepository { row.get("oidc_subject"), )) } - + /// Updates an existing user using a transaction async fn update_user(&self, user: User) -> UserRepositoryResult { // Create a copy of the user for the closure let user_clone = user.clone(); - - with_transaction( - &self.pool, - "update_user", - |tx| { - Box::pin(async move { - // Update the user - sqlx::query( - r#" + + with_transaction(&self.pool, "update_user", |tx| { + Box::pin(async move { + // Update the user + sqlx::query( + r#" UPDATE auth.users SET username = $2, @@ -262,35 +250,39 @@ impl UserRepository for UserPgRepository { last_login_at = $9, active = $10 WHERE id = $1 - "# - ) - .bind(user_clone.id()) - .bind(user_clone.username()) - .bind(user_clone.email()) - .bind(user_clone.password_hash()) - .bind(user_clone.role().to_string()) - .bind(user_clone.storage_quota_bytes()) - .bind(user_clone.storage_used_bytes()) - .bind(user_clone.updated_at()) - .bind(user_clone.last_login_at()) - .bind(user_clone.is_active()) - .execute(&mut **tx) - .await - .map_err(Self::map_sqlx_error)?; - - // We could perform additional operations here inside - // the same transaction, such as updating permissions, etc. - - Ok(user_clone) - }) as BoxFuture<'_, UserRepositoryResult> - } - ).await?; - + "#, + ) + .bind(user_clone.id()) + .bind(user_clone.username()) + .bind(user_clone.email()) + .bind(user_clone.password_hash()) + .bind(user_clone.role().to_string()) + .bind(user_clone.storage_quota_bytes()) + .bind(user_clone.storage_used_bytes()) + .bind(user_clone.updated_at()) + .bind(user_clone.last_login_at()) + .bind(user_clone.is_active()) + .execute(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + // We could perform additional operations here inside + // the same transaction, such as updating permissions, etc. + + Ok(user_clone) + }) as BoxFuture<'_, UserRepositoryResult> + }) + .await?; + Ok(user) } - + /// Updates only the storage usage of a user - async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> UserRepositoryResult<()> { + async fn update_storage_usage( + &self, + user_id: &str, + usage_bytes: i64, + ) -> UserRepositoryResult<()> { sqlx::query( r#" UPDATE auth.users @@ -298,7 +290,7 @@ impl UserRepository for UserPgRepository { storage_used_bytes = $2, updated_at = NOW() WHERE id = $1 - "# + "#, ) .bind(user_id) .bind(usage_bytes) @@ -308,7 +300,7 @@ impl UserRepository for UserPgRepository { Ok(()) } - + /// Updates the last login date async fn update_last_login(&self, user_id: &str) -> UserRepositoryResult<()> { sqlx::query( @@ -318,7 +310,7 @@ impl UserRepository for UserPgRepository { last_login_at = NOW(), updated_at = NOW() WHERE id = $1 - "# + "#, ) .bind(user_id) .execute(&*self.pool) @@ -327,7 +319,7 @@ impl UserRepository for UserPgRepository { Ok(()) } - + /// Lists users with pagination async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult> { let rows = sqlx::query( @@ -340,7 +332,7 @@ impl UserRepository for UserPgRepository { FROM auth.users ORDER BY created_at DESC LIMIT $1 OFFSET $2 - "# + "#, ) .bind(limit) .bind(offset) @@ -348,7 +340,8 @@ impl UserRepository for UserPgRepository { .await .map_err(Self::map_sqlx_error)?; - let users = rows.into_iter() + let users = rows + .into_iter() .map(|row| { // Convert role string to UserRole enum for each row let role_str: Option = row.try_get("role_text").unwrap_or(None); @@ -356,7 +349,7 @@ impl UserRepository for UserPgRepository { Some("admin") => UserRole::Admin, _ => UserRole::User, }; - + User::from_data_full( row.get("id"), row.get("username"), @@ -377,9 +370,13 @@ impl UserRepository for UserPgRepository { Ok(users) } - + /// Activates or deactivates a user - async fn set_user_active_status(&self, user_id: &str, active: bool) -> UserRepositoryResult<()> { + async fn set_user_active_status( + &self, + user_id: &str, + active: bool, + ) -> UserRepositoryResult<()> { sqlx::query( r#" UPDATE auth.users @@ -387,7 +384,7 @@ impl UserRepository for UserPgRepository { active = $2, updated_at = NOW() WHERE id = $1 - "# + "#, ) .bind(user_id) .bind(active) @@ -397,9 +394,13 @@ impl UserRepository for UserPgRepository { Ok(()) } - + /// Changes a user's password - async fn change_password(&self, user_id: &str, password_hash: &str) -> UserRepositoryResult<()> { + async fn change_password( + &self, + user_id: &str, + password_hash: &str, + ) -> UserRepositoryResult<()> { sqlx::query( r#" UPDATE auth.users @@ -407,7 +408,7 @@ impl UserRepository for UserPgRepository { password_hash = $2, updated_at = NOW() WHERE id = $1 - "# + "#, ) .bind(user_id) .bind(password_hash) @@ -417,12 +418,12 @@ impl UserRepository for UserPgRepository { Ok(()) } - + /// Changes a user's role async fn change_role(&self, user_id: &str, role: UserRole) -> UserRepositoryResult<()> { // Convert the role to string for the binding let role_str = role.to_string(); - + sqlx::query( r#" UPDATE auth.users @@ -430,7 +431,7 @@ impl UserRepository for UserPgRepository { role = $2::auth.userrole, updated_at = NOW() WHERE id = $1 - "# + "#, ) .bind(user_id) .bind(&role_str) @@ -440,7 +441,7 @@ impl UserRepository for UserPgRepository { Ok(()) } - + /// Lists users by role async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult> { let rows = sqlx::query( @@ -453,14 +454,15 @@ impl UserRepository for UserPgRepository { FROM auth.users WHERE role::text = $1 ORDER BY created_at DESC - "# + "#, ) .bind(role) .fetch_all(&*self.pool) .await .map_err(Self::map_sqlx_error)?; - let users = rows.into_iter() + let users = rows + .into_iter() .map(|row| { // Convert role string to UserRole enum for each row let role_str: Option = row.try_get("role_text").unwrap_or(None); @@ -468,7 +470,7 @@ impl UserRepository for UserPgRepository { Some("admin") => UserRole::Admin, _ => UserRole::User, }; - + User::from_data_full( row.get("id"), row.get("username"), @@ -489,14 +491,14 @@ impl UserRepository for UserPgRepository { Ok(users) } - + /// Deletes a user async fn delete_user(&self, user_id: &str) -> UserRepositoryResult<()> { sqlx::query( r#" DELETE FROM auth.users WHERE id = $1 - "# + "#, ) .bind(user_id) .execute(&*self.pool) @@ -507,7 +509,11 @@ impl UserRepository for UserPgRepository { } /// Finds a user by OIDC provider + subject pair - async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult { + async fn get_user_by_oidc_subject( + &self, + provider: &str, + subject: &str, + ) -> UserRepositoryResult { let row = sqlx::query( r#" SELECT @@ -517,7 +523,7 @@ impl UserRepository for UserPgRepository { oidc_provider, oidc_subject FROM auth.users WHERE oidc_provider = $1 AND oidc_subject = $2 - "# + "#, ) .bind(provider) .bind(subject) @@ -549,7 +555,11 @@ impl UserRepository for UserPgRepository { } /// Updates a user's storage quota - async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> UserRepositoryResult<()> { + async fn update_storage_quota( + &self, + user_id: &str, + quota_bytes: i64, + ) -> UserRepositoryResult<()> { sqlx::query( r#" UPDATE auth.users @@ -557,7 +567,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes = $2, updated_at = NOW() WHERE id = $1 - "# + "#, ) .bind(user_id) .bind(quota_bytes) @@ -570,12 +580,10 @@ impl UserRepository for UserPgRepository { /// Counts the total number of users async fn count_users(&self) -> UserRepositoryResult { - let row = sqlx::query( - "SELECT COUNT(*) as count FROM auth.users" - ) - .fetch_one(&*self.pool) - .await - .map_err(Self::map_sqlx_error)?; + let row = sqlx::query("SELECT COUNT(*) as count FROM auth.users") + .fetch_one(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; let count: i64 = row.get("count"); Ok(count) @@ -614,52 +622,74 @@ impl UserRepository for UserPgRepository { #[async_trait] impl UserStoragePort for UserPgRepository { async fn create_user(&self, user: User) -> Result { - UserRepository::create_user(self, user).await.map_err(DomainError::from) + UserRepository::create_user(self, user) + .await + .map_err(DomainError::from) } - + async fn get_user_by_id(&self, id: &str) -> Result { - UserRepository::get_user_by_id(self, id).await.map_err(DomainError::from) + UserRepository::get_user_by_id(self, id) + .await + .map_err(DomainError::from) } - + async fn get_user_by_username(&self, username: &str) -> Result { - UserRepository::get_user_by_username(self, username).await.map_err(DomainError::from) + UserRepository::get_user_by_username(self, username) + .await + .map_err(DomainError::from) } - + async fn get_user_by_email(&self, email: &str) -> Result { - UserRepository::get_user_by_email(self, email).await.map_err(DomainError::from) + UserRepository::get_user_by_email(self, email) + .await + .map_err(DomainError::from) } - + async fn update_user(&self, user: User) -> Result { - UserRepository::update_user(self, user).await.map_err(DomainError::from) + UserRepository::update_user(self, user) + .await + .map_err(DomainError::from) } - - async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> Result<(), DomainError> { + + async fn update_storage_usage( + &self, + user_id: &str, + usage_bytes: i64, + ) -> Result<(), DomainError> { UserRepository::update_storage_usage(self, user_id, usage_bytes) .await .map_err(DomainError::from) } - + async fn list_users(&self, limit: i64, offset: i64) -> Result, DomainError> { - UserRepository::list_users(self, limit, offset).await.map_err(DomainError::from) + UserRepository::list_users(self, limit, offset) + .await + .map_err(DomainError::from) } - + async fn list_users_by_role(&self, role: &str) -> Result, DomainError> { - UserRepository::list_users_by_role(self, role).await.map_err(DomainError::from) + UserRepository::list_users_by_role(self, role) + .await + .map_err(DomainError::from) } - + async fn delete_user(&self, user_id: &str) -> Result<(), DomainError> { UserRepository::delete_user(self, user_id) .await .map_err(DomainError::from) } - + async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError> { UserRepository::change_password(self, user_id, password_hash) .await .map_err(DomainError::from) } - async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result { + async fn get_user_by_oidc_subject( + &self, + provider: &str, + subject: &str, + ) -> Result { UserRepository::get_user_by_oidc_subject(self, provider, subject) .await .map_err(DomainError::from) @@ -681,7 +711,11 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } - async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError> { + async fn update_storage_quota( + &self, + user_id: &str, + quota_bytes: i64, + ) -> Result<(), DomainError> { UserRepository::update_storage_quota(self, user_id, quota_bytes) .await .map_err(DomainError::from) @@ -692,4 +726,4 @@ impl UserStoragePort for UserPgRepository { .await .map_err(DomainError::from) } -} \ No newline at end of file +} diff --git a/src/infrastructure/repositories/repository_errors.rs b/src/infrastructure/repositories/repository_errors.rs index bbb6448a..6fa7167a 100644 --- a/src/infrastructure/repositories/repository_errors.rs +++ b/src/infrastructure/repositories/repository_errors.rs @@ -12,22 +12,22 @@ use crate::common::errors::DomainError; pub enum FileRepositoryError { #[error("File not found: {0}")] NotFound(String), - + #[error("File already exists: {0}")] AlreadyExists(String), - + #[error("Invalid file path: {0}")] InvalidPath(String), - + #[error("Operation not supported: {0}")] OperationNotSupported(String), - + #[error("Storage error: {0}")] StorageError(String), - + #[error("Domain error: {0}")] DomainError(#[from] DomainError), - + #[error("Other error: {0}")] Other(String), } @@ -39,25 +39,25 @@ pub type FileRepositoryResult = Result; pub enum FolderRepositoryError { #[error("Folder not found: {0}")] NotFound(String), - + #[error("Folder already exists: {0}")] AlreadyExists(String), - + #[error("Invalid folder path: {0}")] InvalidPath(String), - + #[error("Operation not supported: {0}")] OperationNotSupported(String), - + #[error("Storage error: {0}")] StorageError(String), - + #[error("Validation error: {0}")] ValidationError(String), - + #[error("Domain error: {0}")] DomainError(#[from] DomainError), - + #[error("Other error: {0}")] Other(String), } @@ -71,10 +71,16 @@ impl From for DomainError { match err { FileRepositoryError::NotFound(id) => DomainError::not_found("File", id), FileRepositoryError::AlreadyExists(path) => DomainError::already_exists("File", path), - FileRepositoryError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)), - FileRepositoryError::StorageError(msg) => DomainError::internal_error("File", format!("Storage error: {}", msg)), + FileRepositoryError::InvalidPath(path) => { + DomainError::validation_error(format!("Invalid path: {}", path)) + } + FileRepositoryError::StorageError(msg) => { + DomainError::internal_error("File", format!("Storage error: {}", msg)) + } FileRepositoryError::Other(msg) => DomainError::internal_error("File", msg), - FileRepositoryError::OperationNotSupported(msg) => DomainError::operation_not_supported("File", msg), + FileRepositoryError::OperationNotSupported(msg) => { + DomainError::operation_not_supported("File", msg) + } FileRepositoryError::DomainError(e) => e, } } @@ -84,12 +90,20 @@ impl From for DomainError { fn from(err: FolderRepositoryError) -> Self { match err { FolderRepositoryError::NotFound(id) => DomainError::not_found("Folder", id), - FolderRepositoryError::AlreadyExists(path) => DomainError::already_exists("Folder", path), - FolderRepositoryError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)), - FolderRepositoryError::StorageError(msg) => DomainError::internal_error("Folder", format!("Storage error: {}", msg)), + FolderRepositoryError::AlreadyExists(path) => { + DomainError::already_exists("Folder", path) + } + FolderRepositoryError::InvalidPath(path) => { + DomainError::validation_error(format!("Invalid path: {}", path)) + } + FolderRepositoryError::StorageError(msg) => { + DomainError::internal_error("Folder", format!("Storage error: {}", msg)) + } FolderRepositoryError::ValidationError(msg) => DomainError::validation_error(msg), FolderRepositoryError::Other(msg) => DomainError::internal_error("Folder", msg), - FolderRepositoryError::OperationNotSupported(msg) => DomainError::operation_not_supported("Folder", msg), + FolderRepositoryError::OperationNotSupported(msg) => { + DomainError::operation_not_supported("Folder", msg) + } FolderRepositoryError::DomainError(e) => e, } } diff --git a/src/infrastructure/repositories/share_fs_repository.rs b/src/infrastructure/repositories/share_fs_repository.rs index 0abfcd59..2fc2c419 100644 --- a/src/infrastructure/repositories/share_fs_repository.rs +++ b/src/infrastructure/repositories/share_fs_repository.rs @@ -7,9 +7,7 @@ use tokio::{fs, io}; use crate::{ application::ports::share_ports::ShareStoragePort, common::{config::AppConfig, errors::DomainError}, - domain::{ - entities::share::{Share, ShareItemType}, - }, + domain::entities::share::{Share, ShareItemType}, }; // Structure for storing in the file system @@ -74,8 +72,8 @@ impl ShareFsRepository { /// Converts a file system record to a domain entity fn to_entity(&self, record: &ShareRecord) -> Share { - let item_type = ShareItemType::try_from(record.item_type.as_str()) - .unwrap_or(ShareItemType::File); + let item_type = + ShareItemType::try_from(record.item_type.as_str()).unwrap_or(ShareItemType::File); let permissions = crate::domain::entities::share::SharePermissions::new( record.permissions_read, @@ -119,7 +117,9 @@ impl ShareFsRepository { #[async_trait] impl ShareStoragePort for ShareFsRepository { async fn save_share(&self, share: &Share) -> Result { - let mut shares = self.read_shares().await + let mut shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; // Check if the link already exists @@ -135,21 +135,22 @@ impl ShareStoragePort for ShareFsRepository { shares.push(record); } - self.write_shares(&shares).await + self.write_shares(&shares) + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; Ok(share.clone()) } async fn find_share_by_id(&self, id: &str) -> Result { - let shares = self.read_shares().await + let shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; - let share = shares.iter() - .find(|s| s.id == id) - .ok_or_else(|| { - DomainError::not_found("Share", format!("Share with ID {} not found", id)) - }); + let share = shares.iter().find(|s| s.id == id).ok_or_else(|| { + DomainError::not_found("Share", format!("Share with ID {} not found", id)) + }); match share { Ok(record) => Ok(self.to_entity(record)), @@ -158,14 +159,14 @@ impl ShareStoragePort for ShareFsRepository { } async fn find_share_by_token(&self, token: &str) -> Result { - let shares = self.read_shares().await + let shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; - let share = shares.iter() - .find(|s| s.token == token) - .ok_or_else(|| { - DomainError::not_found("Share", format!("Share with token {} not found", token)) - }); + let share = shares.iter().find(|s| s.token == token).ok_or_else(|| { + DomainError::not_found("Share", format!("Share with token {} not found", token)) + }); match share { Ok(record) => Ok(self.to_entity(record)), @@ -173,12 +174,19 @@ impl ShareStoragePort for ShareFsRepository { } } - async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result, DomainError> { - let shares = self.read_shares().await + async fn find_shares_by_item( + &self, + item_id: &str, + item_type: &ShareItemType, + ) -> Result, DomainError> { + let shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; let type_str = item_type.to_string(); - let result: Vec = shares.iter() + let result: Vec = shares + .iter() .filter(|s| s.item_id == item_id && s.item_type == type_str) .map(|record| self.to_entity(record)) .collect(); @@ -187,27 +195,37 @@ impl ShareStoragePort for ShareFsRepository { } async fn update_share(&self, share: &Share) -> Result { - let mut shares = self.read_shares().await + let mut shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; // Find the index of the link to update - let index = shares.iter().position(|s| s.id == share.id()) + let index = shares + .iter() + .position(|s| s.id == share.id()) .ok_or_else(|| { - DomainError::not_found("Share", format!("Share with ID {} not found for update", share.id())) + DomainError::not_found( + "Share", + format!("Share with ID {} not found for update", share.id()), + ) })?; // Update the record shares[index] = self.to_record(share); // Save changes - self.write_shares(&shares).await + self.write_shares(&shares) + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; Ok(share.clone()) } async fn delete_share(&self, id: &str) -> Result<(), DomainError> { - let mut shares = self.read_shares().await + let mut shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; // Find the index of the link to delete @@ -216,22 +234,34 @@ impl ShareStoragePort for ShareFsRepository { // If no link was deleted, it means it didn't exist if shares.len() == initial_len { - return Err(DomainError::not_found("Share", format!("Share with ID {} not found for deletion", id))); + return Err(DomainError::not_found( + "Share", + format!("Share with ID {} not found for deletion", id), + )); } // Save changes - self.write_shares(&shares).await + self.write_shares(&shares) + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; Ok(()) } - async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec, usize), DomainError> { - let shares = self.read_shares().await + async fn find_shares_by_user( + &self, + user_id: &str, + offset: usize, + limit: usize, + ) -> Result<(Vec, usize), DomainError> { + let shares = self + .read_shares() + .await .map_err(|e| DomainError::internal_error("Share", e.to_string()))?; // Filter the user's links - let user_shares: Vec = shares.into_iter() + let user_shares: Vec = shares + .into_iter() .filter(|s| s.created_by == user_id) .collect(); @@ -239,7 +269,8 @@ impl ShareStoragePort for ShareFsRepository { let total = user_shares.len(); // Apply pagination - let paginated: Vec = user_shares.iter() + let paginated: Vec = user_shares + .iter() .skip(offset) .take(limit) .map(|record| self.to_entity(record)) diff --git a/src/infrastructure/repositories/trash_fs_repository.rs b/src/infrastructure/repositories/trash_fs_repository.rs index 3399949d..d36453ca 100644 --- a/src/infrastructure/repositories/trash_fs_repository.rs +++ b/src/infrastructure/repositories/trash_fs_repository.rs @@ -1,16 +1,16 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use tokio::fs; -use uuid::Uuid; use tracing::{debug, error, instrument}; +use uuid::Uuid; -use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::application::ports::outbound::IdMappingPort; +use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::trash_repository::TrashRepository; -use crate::application::ports::outbound::IdMappingPort; /// Structure for storing trash items in JSON format #[derive(Debug, Serialize, Deserialize)] @@ -38,162 +38,200 @@ impl TrashFsRepository { ) -> Self { let trash_dir = storage_root.as_ref().join(".trash"); let trash_index_path = trash_dir.join("trash_index.json"); - + Self { trash_dir, trash_index_path, } } - + /// Ensures the trash directory exists async fn ensure_trash_dir(&self) -> Result<()> { - debug!("Checking if trash directory exists: {}", self.trash_dir.display()); + debug!( + "Checking if trash directory exists: {}", + self.trash_dir.display() + ); if !self.trash_dir.exists() { - debug!("Trash directory does not exist, creating it: {}", self.trash_dir.display()); - fs::create_dir_all(&self.trash_dir).await - .map_err(|e| { - error!("Failed to create trash directory {}: {}", self.trash_dir.display(), e); - DomainError::new( - ErrorKind::InternalError, - "Trash", - format!("Failed to create trash directory {}: {}", self.trash_dir.display(), e) - ) - })?; + debug!( + "Trash directory does not exist, creating it: {}", + self.trash_dir.display() + ); + fs::create_dir_all(&self.trash_dir).await.map_err(|e| { + error!( + "Failed to create trash directory {}: {}", + self.trash_dir.display(), + e + ); + DomainError::new( + ErrorKind::InternalError, + "Trash", + format!( + "Failed to create trash directory {}: {}", + self.trash_dir.display(), + e + ), + ) + })?; debug!("Trash directory created successfully"); } else { debug!("Trash directory already exists"); } - + // Ensure the files directory exists let files_dir = self.trash_dir.join("files"); - debug!("Checking if trash files directory exists: {}", files_dir.display()); + debug!( + "Checking if trash files directory exists: {}", + files_dir.display() + ); if !files_dir.exists() { - debug!("Trash files directory does not exist, creating it: {}", files_dir.display()); - fs::create_dir_all(&files_dir).await - .map_err(|e| { - error!("Failed to create trash files directory {}: {}", files_dir.display(), e); - DomainError::new( - ErrorKind::InternalError, - "Trash", - format!("Failed to create trash files directory {}: {}", files_dir.display(), e) - ) - })?; + debug!( + "Trash files directory does not exist, creating it: {}", + files_dir.display() + ); + fs::create_dir_all(&files_dir).await.map_err(|e| { + error!( + "Failed to create trash files directory {}: {}", + files_dir.display(), + e + ); + DomainError::new( + ErrorKind::InternalError, + "Trash", + format!( + "Failed to create trash files directory {}: {}", + files_dir.display(), + e + ), + ) + })?; debug!("Trash files directory created successfully"); } else { debug!("Trash files directory already exists"); } - + // Also ensure the folders directory exists let folders_dir = self.trash_dir.join("folders"); - debug!("Checking if trash folders directory exists: {}", folders_dir.display()); + debug!( + "Checking if trash folders directory exists: {}", + folders_dir.display() + ); if !folders_dir.exists() { - debug!("Trash folders directory does not exist, creating it: {}", folders_dir.display()); - fs::create_dir_all(&folders_dir).await - .map_err(|e| { - error!("Failed to create trash folders directory {}: {}", folders_dir.display(), e); - DomainError::new( - ErrorKind::InternalError, - "Trash", - format!("Failed to create trash folders directory {}: {}", folders_dir.display(), e) - ) - })?; + debug!( + "Trash folders directory does not exist, creating it: {}", + folders_dir.display() + ); + fs::create_dir_all(&folders_dir).await.map_err(|e| { + error!( + "Failed to create trash folders directory {}: {}", + folders_dir.display(), + e + ); + DomainError::new( + ErrorKind::InternalError, + "Trash", + format!( + "Failed to create trash folders directory {}: {}", + folders_dir.display(), + e + ), + ) + })?; debug!("Trash folders directory created successfully"); } else { debug!("Trash folders directory already exists"); } - + Ok(()) } - + /// Gets all entries from the trash index async fn get_trash_entries(&self) -> Result> { self.ensure_trash_dir().await?; - + if !self.trash_index_path.exists() { return Ok(Vec::new()); } - - let content = fs::read_to_string(&self.trash_index_path).await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "Trash", - format!("Failed to read trash index: {}", e) - ))?; - + + let content = fs::read_to_string(&self.trash_index_path) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "Trash", + format!("Failed to read trash index: {}", e), + ) + })?; + if content.trim().is_empty() { return Ok(Vec::new()); } - - let entries: Vec = serde_json::from_str(&content) - .map_err(|e| DomainError::new( + + let entries: Vec = serde_json::from_str(&content).map_err(|e| { + DomainError::new( ErrorKind::InternalError, "Trash", - format!("Failed to parse trash index: {}", e) - ))?; - + format!("Failed to parse trash index: {}", e), + ) + })?; + Ok(entries) } - + /// Saves all entries to the trash index async fn save_trash_entries(&self, entries: Vec) -> Result<()> { self.ensure_trash_dir().await?; - - let json = serde_json::to_string_pretty(&entries) - .map_err(|e| DomainError::new( + + let json = serde_json::to_string_pretty(&entries).map_err(|e| { + DomainError::new( ErrorKind::InternalError, "Trash", - format!("Failed to serialize trash index: {}", e) - ))?; - - fs::write(&self.trash_index_path, json).await - .map_err(|e| DomainError::new( + format!("Failed to serialize trash index: {}", e), + ) + })?; + + fs::write(&self.trash_index_path, json).await.map_err(|e| { + DomainError::new( ErrorKind::InternalError, "Trash", - format!("Failed to write trash index: {}", e) - ))?; - + format!("Failed to write trash index: {}", e), + ) + })?; + Ok(()) } - + /// Converts a JSON entry to a TrashedItem entity fn entry_to_trashed_item(&self, entry: TrashedItemEntry) -> Result { let item_type = match entry.item_type.as_str() { "file" => TrashedItemType::File, "folder" => TrashedItemType::Folder, - _ => return Err(DomainError::new( - ErrorKind::InvalidInput, - "Trash", - format!("Invalid trashed item type: {}", entry.item_type) - )), + _ => { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Trash", + format!("Invalid trashed item type: {}", entry.item_type), + )); + } }; - - let original_id = Uuid::parse_str(&entry.original_id) - .map_err(|e| DomainError::validation_error( - format!("Invalid original ID format: {}", e) - ))?; - + + let original_id = Uuid::parse_str(&entry.original_id).map_err(|e| { + DomainError::validation_error(format!("Invalid original ID format: {}", e)) + })?; + let id = Uuid::parse_str(&entry.id) - .map_err(|e| DomainError::validation_error( - format!("Invalid ID format: {}", e) - ))?; - + .map_err(|e| DomainError::validation_error(format!("Invalid ID format: {}", e)))?; + let user_id = Uuid::parse_str(&entry.user_id) - .map_err(|e| DomainError::validation_error( - format!("Invalid user ID format: {}", e) - ))?; - + .map_err(|e| DomainError::validation_error(format!("Invalid user ID format: {}", e)))?; + let trashed_at = chrono::DateTime::parse_from_rfc3339(&entry.trashed_at) - .map_err(|e| DomainError::validation_error( - format!("Invalid trashed_at date: {}", e) - ))? + .map_err(|e| DomainError::validation_error(format!("Invalid trashed_at date: {}", e)))? .with_timezone(&Utc); - + let deletion_date = chrono::DateTime::parse_from_rfc3339(&entry.deletion_date) - .map_err(|e| DomainError::validation_error( - format!("Invalid deletion_date: {}", e) - ))? + .map_err(|e| DomainError::validation_error(format!("Invalid deletion_date: {}", e)))? .with_timezone(&Utc); - + Ok(TrashedItem::from_raw( id, original_id, @@ -205,7 +243,7 @@ impl TrashFsRepository { deletion_date, )) } - + /// Converts a TrashedItem entity to a JSON entry fn trashed_item_to_entry(&self, item: &TrashedItem) -> TrashedItemEntry { TrashedItemEntry { @@ -228,55 +266,72 @@ impl TrashFsRepository { impl TrashRepository for TrashFsRepository { #[instrument(skip(self))] async fn add_to_trash(&self, item: &TrashedItem) -> Result<()> { - debug!("Adding item to trash: id={}, user={}", item.id(), item.user_id()); - + debug!( + "Adding item to trash: id={}, user={}", + item.id(), + item.user_id() + ); + // Ensure the trash directory exists for this user - let user_trash_dir = self.trash_dir.join("files").join(item.user_id().to_string()); + let user_trash_dir = self + .trash_dir + .join("files") + .join(item.user_id().to_string()); debug!("User trash directory path: {}", user_trash_dir.display()); - + // Create the user-specific trash directory - debug!("Creating user trash directory: {}", user_trash_dir.display()); + debug!( + "Creating user trash directory: {}", + user_trash_dir.display() + ); match fs::create_dir_all(&user_trash_dir).await { Ok(_) => debug!("User trash directory created successfully"), Err(e) => { - error!("Failed to create user trash directory {}: {}", user_trash_dir.display(), e); + error!( + "Failed to create user trash directory {}: {}", + user_trash_dir.display(), + e + ); return Err(DomainError::new( ErrorKind::InternalError, "Trash", - format!("Failed to create user trash directory: {}", e) + format!("Failed to create user trash directory: {}", e), )); } } - + // Log the current trash entries before adding the new one let mut entries = self.get_trash_entries().await?; debug!("Current trash entries count: {}", entries.len()); - + // Create the entry for the trash index let entry = self.trashed_item_to_entry(item); - debug!("Created trash entry: id={}, original_id={}, name={}", - entry.id, entry.original_id, entry.name); - + debug!( + "Created trash entry: id={}, original_id={}, name={}", + entry.id, entry.original_id, entry.name + ); + // Add the entry to the index and save entries.push(entry); debug!("Saving updated trash index with {} entries", entries.len()); self.save_trash_entries(entries).await?; debug!("Trash index updated successfully"); - + Ok(()) } #[instrument(skip(self))] async fn get_trash_items(&self, user_id: &Uuid) -> Result> { debug!("Getting trash items for user: {}", user_id); - + let entries = self.get_trash_entries().await?; - + let user_id_str = user_id.to_string(); - let user_entries = entries.into_iter() + let user_entries = entries + .into_iter() .filter(|entry| entry.user_id == user_id_str) .collect::>(); - + let mut items = Vec::new(); for entry in user_entries { match self.entry_to_trashed_item(entry) { @@ -284,27 +339,28 @@ impl TrashRepository for TrashFsRepository { Err(e) => error!("Error converting trash entry to item: {}", e), } } - + Ok(items) } #[instrument(skip(self))] async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { debug!("Looking for item in trash: id={}, user={}", id, user_id); - + let entries = self.get_trash_entries().await?; - + let id_str = id.to_string(); let user_id_str = user_id.to_string(); - - let item_entry = entries.into_iter() + + let item_entry = entries + .into_iter() .find(|entry| entry.id == id_str && entry.user_id == user_id_str); - + match item_entry { Some(entry) => { let item = self.entry_to_trashed_item(entry)?; Ok(Some(item)) - }, + } None => Ok(None), } } @@ -312,16 +368,16 @@ impl TrashRepository for TrashFsRepository { #[instrument(skip(self))] async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> { debug!("Restoring item from trash: id={}, user={}", id, user_id); - + let mut entries = self.get_trash_entries().await?; - + let id_str = id.to_string(); let user_id_str = user_id.to_string(); - - let index = entries.iter().position(|entry| - entry.id == id_str && entry.user_id == user_id_str - ); - + + let index = entries + .iter() + .position(|entry| entry.id == id_str && entry.user_id == user_id_str); + if let Some(index) = index { entries.remove(index); self.save_trash_entries(entries).await?; @@ -333,8 +389,11 @@ impl TrashRepository for TrashFsRepository { #[instrument(skip(self))] async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()> { - debug!("Permanently deleting item from trash: id={}, user={}", id, user_id); - + debug!( + "Permanently deleting item from trash: id={}, user={}", + id, user_id + ); + // Simply remove the entry from the index // Physical files will be deleted through the corresponding repository self.restore_from_trash(id, user_id).await @@ -343,25 +402,25 @@ impl TrashRepository for TrashFsRepository { #[instrument(skip(self))] async fn clear_trash(&self, user_id: &Uuid) -> Result<()> { debug!("Clearing trash for user: {}", user_id); - + let mut entries = self.get_trash_entries().await?; let user_id_str = user_id.to_string(); - + entries.retain(|entry| entry.user_id != user_id_str); self.save_trash_entries(entries).await?; - + Ok(()) } #[instrument(skip(self))] async fn get_expired_items(&self) -> Result> { debug!("Looking for expired trash items"); - + let entries = self.get_trash_entries().await?; let now = Utc::now(); - + let mut expired_items = Vec::new(); - + for entry in entries { match chrono::DateTime::parse_from_rfc3339(&entry.deletion_date) { Ok(date) => { @@ -372,11 +431,11 @@ impl TrashRepository for TrashFsRepository { Err(e) => error!("Error converting expired trash entry: {}", e), } } - }, + } Err(e) => error!("Invalid date format in trash entry: {}", e), } } - + Ok(expired_items) } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/buffer_pool.rs b/src/infrastructure/services/buffer_pool.rs index 07c5d0fa..41bb7c72 100644 --- a/src/infrastructure/services/buffer_pool.rs +++ b/src/infrastructure/services/buffer_pool.rs @@ -1,8 +1,8 @@ use std::cmp::min; use std::collections::VecDeque; use std::sync::Arc; -use tokio::sync::{Mutex, Semaphore}; use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, Semaphore}; use tracing::debug; /// Default buffer size in the pool @@ -79,16 +79,12 @@ impl BufferPool { buffer_ttl: Duration::from_secs(buffer_ttl_secs), }) } - + /// Creates a pool with default configuration pub fn default() -> Arc { - Self::new( - DEFAULT_BUFFER_SIZE, - DEFAULT_MAX_BUFFERS, - DEFAULT_BUFFER_TTL - ) + Self::new(DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFERS, DEFAULT_BUFFER_TTL) } - + /// Gets a buffer from the pool or creates a new one if needed. /// This version takes an Arc to ensure the BorrowedBuffer keeps a proper /// reference to the shared pool (not a clone). @@ -99,7 +95,7 @@ impl BufferPool { let mut stats = self.stats.lock().await; stats.gets += 1; } - + // Concurrency control // Acquire a semaphore permit. If none available, wait. // We forget() the permit so it doesn't auto-release on drop. @@ -113,19 +109,23 @@ impl BufferPool { stats.waits += 1; stats.max_buffers_reached += 1; } - + debug!("Buffer pool: waiting for available buffer"); - let permit = self.limit.acquire().await.expect("Semaphore should not be closed"); + let permit = self + .limit + .acquire() + .await + .expect("Semaphore should not be closed"); debug!("Buffer pool: acquired buffer after waiting"); permit.forget(); } }; - + // Try to get an existing buffer from the pool let mut pool_locked = self.pool.lock().await; - + let pool_arc = Arc::clone(self); - + if let Some(mut pooled_buffer) = pool_locked.pop_front() { // Check if the buffer has expired if pooled_buffer.last_used.elapsed() > self.buffer_ttl { @@ -134,12 +134,12 @@ impl BufferPool { stats.evictions += 1; stats.misses += 1; drop(stats); - + debug!("Buffer pool: evicted expired buffer"); - + // Create new buffer (reusing the permit) drop(pool_locked); // Release the lock before returning - + BorrowedBuffer { buffer: vec![0; self.buffer_size], used_size: 0, @@ -151,13 +151,13 @@ impl BufferPool { let mut stats = self.stats.lock().await; stats.hits += 1; drop(stats); - + // Release the lock before returning drop(pool_locked); - + // Clear buffer for security pooled_buffer.buffer.fill(0); - + BorrowedBuffer { buffer: pooled_buffer.buffer, used_size: 0, @@ -170,12 +170,12 @@ impl BufferPool { let mut stats = self.stats.lock().await; stats.misses += 1; drop(stats); - + // Release the lock before returning drop(pool_locked); - + debug!("Buffer pool: creating new buffer"); - + BorrowedBuffer { buffer: vec![0; self.buffer_size], used_size: 0, @@ -184,90 +184,97 @@ impl BufferPool { } } } - + /// Returns a buffer to the pool async fn return_buffer(&self, mut buffer: Vec) { // If the buffer is the wrong size, discard it if buffer.capacity() != self.buffer_size { - debug!("Buffer pool: discarding buffer of wrong size: {} (expected {})", - buffer.capacity(), self.buffer_size); + debug!( + "Buffer pool: discarding buffer of wrong size: {} (expected {})", + buffer.capacity(), + self.buffer_size + ); // Release the semaphore permit even if we discard the buffer self.limit.add_permits(1); return; } - + // Resize to ensure correct capacity buffer.resize(self.buffer_size, 0); - + // Add to the pool let mut pool_locked = self.pool.lock().await; - + pool_locked.push_back(PooledBuffer { buffer, last_used: Instant::now(), }); - + // Update statistics let mut stats = self.stats.lock().await; stats.returns += 1; - + // Release the semaphore permit so another caller can acquire a buffer drop(pool_locked); drop(stats); self.limit.add_permits(1); } - + /// Cleans expired buffers from the pool pub async fn clean_expired_buffers(&self) { let _now = Instant::now(); let mut pool_locked = self.pool.lock().await; - + // Count expired let count_before = pool_locked.len(); - + // Filter keeping only non-expired - pool_locked.retain(|buffer| { - buffer.last_used.elapsed() <= self.buffer_ttl - }); - + pool_locked.retain(|buffer| buffer.last_used.elapsed() <= self.buffer_ttl); + // Count how many were removed let removed = count_before - pool_locked.len(); - + if removed > 0 { // Update statistics let mut stats = self.stats.lock().await; stats.evictions += removed; - + debug!("Buffer pool: cleaned {} expired buffers", removed); } } - + /// Gets current pool statistics pub async fn get_stats(&self) -> BufferPoolStats { self.stats.lock().await.clone() } - + /// Starts the periodic cleanup task pub fn start_cleaner(pool: Arc) { tokio::spawn(async move { let interval = Duration::from_secs(30); // Clean every 30 seconds - + loop { tokio::time::sleep(interval).await; pool.clean_expired_buffers().await; - + // Log statistics periodically let stats = pool.get_stats().await; - debug!("Buffer pool stats: gets={}, hits={}, misses={}, hit_ratio={:.2}%, returns={}, \ + debug!( + "Buffer pool stats: gets={}, hits={}, misses={}, hit_ratio={:.2}%, returns={}, \ evictions={}, max_reached={}, waits={}", - stats.gets, - stats.hits, - stats.misses, - if stats.gets > 0 { (stats.hits as f64 * 100.0) / stats.gets as f64 } else { 0.0 }, - stats.returns, - stats.evictions, - stats.max_buffers_reached, - stats.waits); + stats.gets, + stats.hits, + stats.misses, + if stats.gets > 0 { + (stats.hits as f64 * 100.0) / stats.gets as f64 + } else { + 0.0 + }, + stats.returns, + stats.evictions, + stats.max_buffers_reached, + stats.waits + ); } }); } @@ -290,26 +297,26 @@ impl BorrowedBuffer { pub fn as_mut_slice(&mut self) -> &mut [u8] { &mut self.buffer } - + /// Gets a reference to the used data pub fn as_slice(&self) -> &[u8] { &self.buffer[..self.used_size] } - + /// Sets how many bytes were actually used pub fn set_used(&mut self, size: usize) { self.used_size = min(size, self.buffer.len()); } - + /// Converts into a Vec that includes only the used data pub fn into_vec(mut self) -> Vec { // Mark to not return to pool self.return_to_pool = false; - + // Create a new vector with only the used data self.buffer[..self.used_size].to_vec() } - + /// Copies data to this buffer and updates the used size pub fn copy_from_slice(&mut self, data: &[u8]) -> usize { let copy_size = min(data.len(), self.buffer.len()); @@ -317,18 +324,18 @@ impl BorrowedBuffer { self.used_size = copy_size; copy_size } - + /// Prevents the buffer from being returned to the pool on destruction pub fn do_not_return(mut self) -> Self { self.return_to_pool = false; self } - + /// Gets the total buffer size pub fn capacity(&self) -> usize { self.buffer.len() } - + /// Gets the used buffer size pub fn used_size(&self) -> usize { self.used_size @@ -342,7 +349,7 @@ impl Drop for BorrowedBuffer { // Take ownership of the buffer and create a clone of the pool let buffer = std::mem::take(&mut self.buffer); let pool = self.pool.clone(); - + // Spawn the return so that drop doesn't block // return_buffer will release the semaphore permit tokio::spawn(async move { @@ -358,140 +365,140 @@ impl Drop for BorrowedBuffer { #[cfg(test)] mod tests { use super::*; - + #[tokio::test] async fn test_buffer_pooling() { // Create small pool for testing let pool = BufferPool::new(1024, 5, 60); - + // Get a buffer let mut buffer1 = pool.get_buffer().await; buffer1.copy_from_slice(b"test data"); assert_eq!(buffer1.as_slice(), b"test data"); - + // Get another buffer let buffer2 = pool.get_buffer().await; - + // Verify stats let stats = pool.get_stats().await; assert_eq!(stats.gets, 2); assert_eq!(stats.hits, 0); // no hits yet assert_eq!(stats.misses, 2); // all are misses - + // Return buffer1 to pool (implicitly via drop) drop(buffer1); - + // Allow the async return to occur tokio::time::sleep(Duration::from_millis(10)).await; - + // Get another buffer (should reuse the returned one) let buffer3 = pool.get_buffer().await; - + // Verify updated stats let stats = pool.get_stats().await; assert_eq!(stats.gets, 3); assert_eq!(stats.hits, 1); // now there should be a hit assert_eq!(stats.returns, 1); // one buffer returned - + // Cleanup drop(buffer2); drop(buffer3); } - + #[tokio::test] async fn test_buffer_operations() { let pool = BufferPool::new(1024, 10, 60); - + // Get buffer let mut buffer = pool.get_buffer().await; - + // Write data buffer.copy_from_slice(b"Hello, world!"); assert_eq!(buffer.used_size(), 13); assert_eq!(buffer.as_slice(), b"Hello, world!"); - + // Convert to vec and verify let vec = buffer.into_vec(); // This prevents returning to pool assert_eq!(vec, b"Hello, world!"); - + // Verify that returns are not incremented (buffer not returned) tokio::time::sleep(Duration::from_millis(10)).await; let stats = pool.get_stats().await; assert_eq!(stats.returns, 0); } - + #[tokio::test] async fn test_pool_limit() { // Pool with only 3 buffers let pool = BufferPool::new(1024, 3, 60); - + // Get 3 buffers (reaches the limit) let buffer1 = pool.get_buffer().await; let buffer2 = pool.get_buffer().await; let buffer3 = pool.get_buffer().await; - + // Verify stats let stats = pool.get_stats().await; assert_eq!(stats.gets, 3); assert_eq!(stats.waits, 0); // no waits yet - + // Try to get a 4th buffer in a separate task (should wait) let pool_clone = pool.clone(); let handle = tokio::spawn(async move { let _buffer4 = pool_clone.get_buffer().await; true }); - + // Give time for the task to try to take the buffer tokio::time::sleep(Duration::from_millis(50)).await; - + // Verify there is a wait let stats = pool.get_stats().await; assert_eq!(stats.waits, 1); - + // Release a buffer drop(buffer1); - + // Give time for the async return and for the waiting task to get its buffer tokio::time::sleep(Duration::from_millis(50)).await; - + // Verify the task was able to continue assert!(handle.await.unwrap()); - + // Cleanup drop(buffer2); drop(buffer3); } - + #[tokio::test] async fn test_ttl_expiration() { // Pool with very short TTL for testing let pool = BufferPool::new(1024, 5, 1); // 1 second TTL - + // Get and return a buffer let buffer = pool.get_buffer().await; drop(buffer); - + // Allow the async return to occur tokio::time::sleep(Duration::from_millis(50)).await; - + // Verify there is a buffer in the pool let stats = pool.get_stats().await; assert_eq!(stats.returns, 1); - + // Wait for the TTL to expire tokio::time::sleep(Duration::from_secs(2)).await; - + // Clean expired pool.clean_expired_buffers().await; - + // Get another buffer (should be a miss since the previous one expired) let _buffer2 = pool.get_buffer().await; - + // Verify stats let stats = pool.get_stats().await; assert_eq!(stats.evictions, 1); // one expired buffer assert_eq!(stats.hits, 0); // no hits (the buffer expired) assert_eq!(stats.misses, 2); // two misses (1st and 3rd get) } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index 3c6228e8..91ad241a 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -1,629 +1,662 @@ -//! Chunked Upload Service - TUS-like Protocol for Large File Uploads -//! -//! Enables parallel chunk uploads for files >10MB with: -//! - Resumable uploads (persist progress) -//! - Parallel chunk transfers (up to 6 concurrent) -//! - Automatic reassembly -//! - Expiration cleanup (24h) -//! -//! Protocol: -//! 1. POST /api/uploads → Create upload session, get upload_id -//! 2. PATCH /api/uploads/:id → Upload chunks (parallel OK) -//! 3. HEAD /api/uploads/:id → Check progress -//! 4. POST /api/uploads/:id/complete → Finalize and assemble - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::fs::{self, File, OpenOptions}; -use tokio::io::AsyncWriteExt; -use tokio::sync::RwLock; -use uuid::Uuid; -use async_trait::async_trait; - -use crate::application::ports::chunked_upload_ports::{ - ChunkedUploadPort, - CreateUploadResponseDto, - ChunkUploadResponseDto, - UploadStatusResponseDto, -}; -use crate::domain::errors::{DomainError, ErrorKind}; - -/// Minimum file size to use chunked upload (10MB) -pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024; - -/// Default chunk size (5MB) - optimized for parallel transfers -pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024; - -/// Maximum concurrent chunks per upload -pub const MAX_PARALLEL_CHUNKS: usize = 6; - -/// Upload session expiration time -const SESSION_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours - -/// Chunk status -#[derive(Debug, Clone, PartialEq)] -pub enum ChunkStatus { - Pending, - Uploading, - Complete, - Failed(String), -} - -/// Individual chunk metadata -#[derive(Debug, Clone)] -pub struct ChunkInfo { - pub index: usize, - pub offset: u64, - pub size: usize, - pub status: ChunkStatus, - pub checksum: Option, -} - -/// Upload session state -#[derive(Debug, Clone)] -pub struct UploadSession { - pub id: String, - pub filename: String, - pub folder_id: Option, - pub content_type: String, - pub total_size: u64, - pub chunk_size: usize, - pub chunks: Vec, - pub created_at: Instant, - pub last_activity: Instant, - pub temp_dir: PathBuf, - pub bytes_received: u64, -} - -impl UploadSession { - /// Calculate number of chunks needed - pub fn calculate_chunk_count(total_size: u64, chunk_size: usize) -> usize { - (total_size as usize).div_ceil(chunk_size).max(1) - } - - /// Get upload progress (0.0 - 1.0) - pub fn progress(&self) -> f64 { - if self.total_size == 0 { - return 1.0; - } - self.bytes_received as f64 / self.total_size as f64 - } - - /// Check if all chunks are complete - pub fn is_complete(&self) -> bool { - self.chunks.iter().all(|c| c.status == ChunkStatus::Complete) - } - - /// Get pending chunk indices - pub fn pending_chunks(&self) -> Vec { - self.chunks - .iter() - .enumerate() - .filter(|(_, c)| c.status == ChunkStatus::Pending) - .map(|(i, _)| i) - .collect() - } - - /// Check if session has expired - pub fn is_expired(&self) -> bool { - self.last_activity.elapsed() > SESSION_EXPIRATION - } -} - -/// Response for upload session creation -#[derive(Debug, Clone, serde::Serialize)] -pub struct CreateUploadResponse { - pub upload_id: String, - pub chunk_size: usize, - pub total_chunks: usize, - pub expires_at: u64, -} - -/// Response for chunk upload -#[derive(Debug, Clone, serde::Serialize)] -pub struct ChunkUploadResponse { - pub chunk_index: usize, - pub bytes_received: u64, - pub progress: f64, - pub is_complete: bool, -} - -/// Response for upload status -#[derive(Debug, Clone, serde::Serialize)] -pub struct UploadStatusResponse { - pub upload_id: String, - pub filename: String, - pub total_size: u64, - pub bytes_received: u64, - pub progress: f64, - pub total_chunks: usize, - pub completed_chunks: usize, - pub pending_chunks: Vec, - pub is_complete: bool, -} - -/// Chunked Upload Service -pub struct ChunkedUploadService { - sessions: Arc>>, - temp_base_dir: PathBuf, -} - -impl ChunkedUploadService { - /// Create new service with temp directory for chunks - pub fn new(temp_base_dir: PathBuf) -> Self { - let service = Self { - sessions: Arc::new(RwLock::new(HashMap::new())), - temp_base_dir, - }; - - // Start cleanup task - let sessions_clone = service.sessions.clone(); - let temp_dir_clone = service.temp_base_dir.clone(); - tokio::spawn(async move { - Self::cleanup_loop(sessions_clone, temp_dir_clone).await; - }); - - service - } - - /// Background task to clean expired sessions - async fn cleanup_loop( - sessions: Arc>>, - temp_base_dir: PathBuf, - ) { - let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour - - loop { - interval.tick().await; - - let expired: Vec = { - let sessions = sessions.read().await; - sessions - .iter() - .filter(|(_, s)| s.is_expired()) - .map(|(id, _)| id.clone()) - .collect() - }; - - for id in expired { - let mut sessions = sessions.write().await; - if let Some(session) = sessions.remove(&id) { - // Clean up temp files - if let Err(e) = fs::remove_dir_all(&session.temp_dir).await { - tracing::warn!("Failed to cleanup expired upload {}: {}", id, e); - } else { - tracing::info!("🧹 Cleaned expired upload session: {}", id); - } - } - } - - // Also clean orphaned temp directories - if let Ok(mut entries) = fs::read_dir(&temp_base_dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - if path.is_dir() { - let dir_name = path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); - - // Check if this directory belongs to an active session - let sessions = sessions.read().await; - if !sessions.contains_key(dir_name) { - // Check if directory is old (>24h) - if let Ok(metadata) = fs::metadata(&path).await - && let Ok(modified) = metadata.modified() - && modified.elapsed().unwrap_or_default() > SESSION_EXPIRATION { - let _ = fs::remove_dir_all(&path).await; - tracing::info!("🧹 Cleaned orphaned upload dir: {:?}", path); - } - } - } - } - } - } - } - - /// Create a new upload session - pub async fn create_session( - &self, - filename: String, - folder_id: Option, - content_type: String, - total_size: u64, - chunk_size: Option, - ) -> Result { - let upload_id = Uuid::new_v4().to_string(); - let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); - let chunk_count = UploadSession::calculate_chunk_count(total_size, chunk_size); - - // Create temp directory for chunks - let temp_dir = self.temp_base_dir.join(&upload_id); - fs::create_dir_all(&temp_dir).await - .map_err(|e| format!("Failed to create temp directory: {}", e))?; - - // Initialize chunk metadata - let mut chunks = Vec::with_capacity(chunk_count); - let mut offset: u64 = 0; - - for i in 0..chunk_count { - let size = if i == chunk_count - 1 { - // Last chunk may be smaller - (total_size - offset) as usize - } else { - chunk_size - }; - - chunks.push(ChunkInfo { - index: i, - offset, - size, - status: ChunkStatus::Pending, - checksum: None, - }); - - offset += size as u64; - } - - let now = Instant::now(); - let session = UploadSession { - id: upload_id.clone(), - filename, - folder_id, - content_type, - total_size, - chunk_size, - chunks, - created_at: now, - last_activity: now, - temp_dir, - bytes_received: 0, - }; - - let expires_at = SESSION_EXPIRATION.as_secs(); - - { - let mut sessions = self.sessions.write().await; - sessions.insert(upload_id.clone(), session); - } - - tracing::info!( - "📤 Created chunked upload session: {} ({} chunks, {} bytes each)", - upload_id, chunk_count, chunk_size - ); - - Ok(CreateUploadResponse { - upload_id, - chunk_size, - total_chunks: chunk_count, - expires_at, - }) - } - - /// Upload a single chunk - pub async fn upload_chunk( - &self, - upload_id: &str, - chunk_index: usize, - data: bytes::Bytes, - checksum: Option, - ) -> Result { - // Validate session exists and chunk index is valid - let (chunk_path, expected_size) = { - let sessions = self.sessions.read().await; - let session = sessions.get(upload_id) - .ok_or_else(|| format!("Upload session not found: {}", upload_id))?; - - if chunk_index >= session.chunks.len() { - return Err(format!("Invalid chunk index: {} (max: {})", - chunk_index, session.chunks.len() - 1)); - } - - let chunk = &session.chunks[chunk_index]; - if chunk.status == ChunkStatus::Complete { - return Err(format!("Chunk {} already uploaded", chunk_index)); - } - - (session.temp_dir.join(format!("chunk_{:06}", chunk_index)), chunk.size) - }; - - // Validate chunk size - if data.len() != expected_size { - return Err(format!( - "Invalid chunk size: expected {} bytes, got {} bytes", - expected_size, data.len() - )); - } - - // Verify checksum if provided - if let Some(ref expected_checksum) = checksum { - let actual_checksum = format!("{:x}", md5::compute(&data)); - if &actual_checksum != expected_checksum { - return Err(format!( - "Checksum mismatch: expected {}, got {}", - expected_checksum, actual_checksum - )); - } - } - - // Write chunk to temp file - let mut file = File::create(&chunk_path).await - .map_err(|e| format!("Failed to create chunk file: {}", e))?; - - file.write_all(&data).await - .map_err(|e| format!("Failed to write chunk: {}", e))?; - - file.sync_all().await - .map_err(|e| format!("Failed to sync chunk: {}", e))?; - - // Update session state - let (bytes_received, progress, is_complete) = { - let mut sessions = self.sessions.write().await; - let session = sessions.get_mut(upload_id) - .ok_or_else(|| "Session disappeared".to_string())?; - - session.chunks[chunk_index].status = ChunkStatus::Complete; - session.chunks[chunk_index].checksum = checksum; - session.bytes_received += data.len() as u64; - session.last_activity = Instant::now(); - - (session.bytes_received, session.progress(), session.is_complete()) - }; - - tracing::debug!( - "📦 Chunk {}/{} uploaded for {} ({:.1}% complete)", - chunk_index + 1, - expected_size, - upload_id, - progress * 100.0 - ); - - Ok(ChunkUploadResponse { - chunk_index, - bytes_received, - progress, - is_complete, - }) - } - - /// Get upload status - pub async fn get_status(&self, upload_id: &str) -> Result { - let sessions = self.sessions.read().await; - let session = sessions.get(upload_id) - .ok_or_else(|| format!("Upload session not found: {}", upload_id))?; - - let completed_chunks = session.chunks - .iter() - .filter(|c| c.status == ChunkStatus::Complete) - .count(); - - Ok(UploadStatusResponse { - upload_id: session.id.clone(), - filename: session.filename.clone(), - total_size: session.total_size, - bytes_received: session.bytes_received, - progress: session.progress(), - total_chunks: session.chunks.len(), - completed_chunks, - pending_chunks: session.pending_chunks(), - is_complete: session.is_complete(), - }) - } - - /// Assemble chunks into final file and return the path - /// Returns (assembled_file_path, filename, folder_id, content_type, total_size) - pub async fn complete_upload( - &self, - upload_id: &str, - ) -> Result<(PathBuf, String, Option, String, u64), String> { - // Get session and validate completion - let session = { - let sessions = self.sessions.read().await; - let session = sessions.get(upload_id) - .ok_or_else(|| format!("Upload session not found: {}", upload_id))?; - - if !session.is_complete() { - let pending = session.pending_chunks(); - return Err(format!( - "Upload not complete. Missing chunks: {:?}", - pending - )); - } - - session.clone() - }; - - // Assemble file - let assembled_path = session.temp_dir.join("assembled"); - let mut output = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&assembled_path) - .await - .map_err(|e| format!("Failed to create assembled file: {}", e))?; - - // Append chunks in order - for chunk in &session.chunks { - let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); - let chunk_data = fs::read(&chunk_path).await - .map_err(|e| format!("Failed to read chunk {}: {}", chunk.index, e))?; - - output.write_all(&chunk_data).await - .map_err(|e| format!("Failed to write chunk {} to assembled file: {}", chunk.index, e))?; - } - - output.sync_all().await - .map_err(|e| format!("Failed to sync assembled file: {}", e))?; - - // Clean up chunk files (keep assembled) - for chunk in &session.chunks { - let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); - let _ = fs::remove_file(&chunk_path).await; - } - - tracing::info!( - "✅ Assembled chunked upload: {} ({} bytes from {} chunks)", - session.filename, - session.total_size, - session.chunks.len() - ); - - Ok(( - assembled_path, - session.filename.clone(), - session.folder_id.clone(), - session.content_type.clone(), - session.total_size, - )) - } - - /// Finalize upload: move assembled file to final location and cleanup session - pub async fn finalize_upload(&self, upload_id: &str) -> Result<(), String> { - let mut sessions = self.sessions.write().await; - if let Some(session) = sessions.remove(upload_id) { - // Clean up entire temp directory - if let Err(e) = fs::remove_dir_all(&session.temp_dir).await { - tracing::warn!("Failed to cleanup upload {}: {}", upload_id, e); - } - } - Ok(()) - } - - /// Cancel an upload and cleanup - pub async fn cancel_upload(&self, upload_id: &str) -> Result<(), String> { - let mut sessions = self.sessions.write().await; - if let Some(session) = sessions.remove(upload_id) { - if let Err(e) = fs::remove_dir_all(&session.temp_dir).await { - tracing::warn!("Failed to cleanup cancelled upload {}: {}", upload_id, e); - } - tracing::info!("❌ Cancelled chunked upload: {}", upload_id); - } - Ok(()) - } - - /// Check if file size qualifies for chunked upload - pub fn should_use_chunked(size: u64) -> bool { - size as usize >= CHUNKED_UPLOAD_THRESHOLD - } - - /// Get active session count (for monitoring) - pub async fn active_sessions(&self) -> usize { - self.sessions.read().await.len() - } -} - -// ─── Port implementation ───────────────────────────────────────────────────── - -#[async_trait] -impl ChunkedUploadPort for ChunkedUploadService { - async fn create_session( - &self, - filename: String, - folder_id: Option, - content_type: String, - total_size: u64, - chunk_size: Option, - ) -> Result { - let resp = self.create_session(filename, folder_id, content_type, total_size, chunk_size).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?; - Ok(CreateUploadResponseDto { - upload_id: resp.upload_id, - chunk_size: resp.chunk_size, - total_chunks: resp.total_chunks, - expires_at: resp.expires_at, - }) - } - - async fn upload_chunk( - &self, - upload_id: &str, - chunk_index: usize, - data: bytes::Bytes, - checksum: Option, - ) -> Result { - let resp = self.upload_chunk(upload_id, chunk_index, data, checksum).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?; - Ok(ChunkUploadResponseDto { - chunk_index: resp.chunk_index, - bytes_received: resp.bytes_received, - progress: resp.progress, - is_complete: resp.is_complete, - }) - } - - async fn get_status( - &self, - upload_id: &str, - ) -> Result { - let resp = self.get_status(upload_id).await - .map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))?; - Ok(UploadStatusResponseDto { - upload_id: resp.upload_id, - filename: resp.filename, - total_size: resp.total_size, - bytes_received: resp.bytes_received, - progress: resp.progress, - total_chunks: resp.total_chunks, - completed_chunks: resp.completed_chunks, - pending_chunks: resp.pending_chunks, - is_complete: resp.is_complete, - }) - } - - async fn complete_upload( - &self, - upload_id: &str, - ) -> Result<(PathBuf, String, Option, String, u64), DomainError> { - self.complete_upload(upload_id).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) - } - - async fn finalize_upload( - &self, - upload_id: &str, - ) -> Result<(), DomainError> { - self.finalize_upload(upload_id).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) - } - - async fn cancel_upload( - &self, - upload_id: &str, - ) -> Result<(), DomainError> { - self.cancel_upload(upload_id).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) - } - - fn should_use_chunked(&self, size: u64) -> bool { - ChunkedUploadService::should_use_chunked(size) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_chunk_count_calculation() { - // 10MB file with 5MB chunks = 2 chunks - assert_eq!(UploadSession::calculate_chunk_count(10 * 1024 * 1024, 5 * 1024 * 1024), 2); - - // 11MB file with 5MB chunks = 3 chunks - assert_eq!(UploadSession::calculate_chunk_count(11 * 1024 * 1024, 5 * 1024 * 1024), 3); - - // 1 byte file = 1 chunk - assert_eq!(UploadSession::calculate_chunk_count(1, 5 * 1024 * 1024), 1); - - // 0 byte file = 1 chunk - assert_eq!(UploadSession::calculate_chunk_count(0, 5 * 1024 * 1024), 1); - } - - #[test] - fn test_should_use_chunked() { - assert!(!ChunkedUploadService::should_use_chunked(9 * 1024 * 1024)); - assert!(ChunkedUploadService::should_use_chunked(10 * 1024 * 1024)); - assert!(ChunkedUploadService::should_use_chunked(100 * 1024 * 1024)); - } -} +//! Chunked Upload Service - TUS-like Protocol for Large File Uploads +//! +//! Enables parallel chunk uploads for files >10MB with: +//! - Resumable uploads (persist progress) +//! - Parallel chunk transfers (up to 6 concurrent) +//! - Automatic reassembly +//! - Expiration cleanup (24h) +//! +//! Protocol: +//! 1. POST /api/uploads → Create upload session, get upload_id +//! 2. PATCH /api/uploads/:id → Upload chunks (parallel OK) +//! 3. HEAD /api/uploads/:id → Check progress +//! 4. POST /api/uploads/:id/complete → Finalize and assemble + +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::AsyncWriteExt; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::application::ports::chunked_upload_ports::{ + ChunkUploadResponseDto, ChunkedUploadPort, CreateUploadResponseDto, UploadStatusResponseDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; + +/// Minimum file size to use chunked upload (10MB) +pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024; + +/// Default chunk size (5MB) - optimized for parallel transfers +pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024; + +/// Maximum concurrent chunks per upload +pub const MAX_PARALLEL_CHUNKS: usize = 6; + +/// Upload session expiration time +const SESSION_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours + +/// Chunk status +#[derive(Debug, Clone, PartialEq)] +pub enum ChunkStatus { + Pending, + Uploading, + Complete, + Failed(String), +} + +/// Individual chunk metadata +#[derive(Debug, Clone)] +pub struct ChunkInfo { + pub index: usize, + pub offset: u64, + pub size: usize, + pub status: ChunkStatus, + pub checksum: Option, +} + +/// Upload session state +#[derive(Debug, Clone)] +pub struct UploadSession { + pub id: String, + pub filename: String, + pub folder_id: Option, + pub content_type: String, + pub total_size: u64, + pub chunk_size: usize, + pub chunks: Vec, + pub created_at: Instant, + pub last_activity: Instant, + pub temp_dir: PathBuf, + pub bytes_received: u64, +} + +impl UploadSession { + /// Calculate number of chunks needed + pub fn calculate_chunk_count(total_size: u64, chunk_size: usize) -> usize { + (total_size as usize).div_ceil(chunk_size).max(1) + } + + /// Get upload progress (0.0 - 1.0) + pub fn progress(&self) -> f64 { + if self.total_size == 0 { + return 1.0; + } + self.bytes_received as f64 / self.total_size as f64 + } + + /// Check if all chunks are complete + pub fn is_complete(&self) -> bool { + self.chunks + .iter() + .all(|c| c.status == ChunkStatus::Complete) + } + + /// Get pending chunk indices + pub fn pending_chunks(&self) -> Vec { + self.chunks + .iter() + .enumerate() + .filter(|(_, c)| c.status == ChunkStatus::Pending) + .map(|(i, _)| i) + .collect() + } + + /// Check if session has expired + pub fn is_expired(&self) -> bool { + self.last_activity.elapsed() > SESSION_EXPIRATION + } +} + +/// Response for upload session creation +#[derive(Debug, Clone, serde::Serialize)] +pub struct CreateUploadResponse { + pub upload_id: String, + pub chunk_size: usize, + pub total_chunks: usize, + pub expires_at: u64, +} + +/// Response for chunk upload +#[derive(Debug, Clone, serde::Serialize)] +pub struct ChunkUploadResponse { + pub chunk_index: usize, + pub bytes_received: u64, + pub progress: f64, + pub is_complete: bool, +} + +/// Response for upload status +#[derive(Debug, Clone, serde::Serialize)] +pub struct UploadStatusResponse { + pub upload_id: String, + pub filename: String, + pub total_size: u64, + pub bytes_received: u64, + pub progress: f64, + pub total_chunks: usize, + pub completed_chunks: usize, + pub pending_chunks: Vec, + pub is_complete: bool, +} + +/// Chunked Upload Service +pub struct ChunkedUploadService { + sessions: Arc>>, + temp_base_dir: PathBuf, +} + +impl ChunkedUploadService { + /// Create new service with temp directory for chunks + pub fn new(temp_base_dir: PathBuf) -> Self { + let service = Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + temp_base_dir, + }; + + // Start cleanup task + let sessions_clone = service.sessions.clone(); + let temp_dir_clone = service.temp_base_dir.clone(); + tokio::spawn(async move { + Self::cleanup_loop(sessions_clone, temp_dir_clone).await; + }); + + service + } + + /// Background task to clean expired sessions + async fn cleanup_loop( + sessions: Arc>>, + temp_base_dir: PathBuf, + ) { + let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour + + loop { + interval.tick().await; + + let expired: Vec = { + let sessions = sessions.read().await; + sessions + .iter() + .filter(|(_, s)| s.is_expired()) + .map(|(id, _)| id.clone()) + .collect() + }; + + for id in expired { + let mut sessions = sessions.write().await; + if let Some(session) = sessions.remove(&id) { + // Clean up temp files + if let Err(e) = fs::remove_dir_all(&session.temp_dir).await { + tracing::warn!("Failed to cleanup expired upload {}: {}", id, e); + } else { + tracing::info!("🧹 Cleaned expired upload session: {}", id); + } + } + } + + // Also clean orphaned temp directories + if let Ok(mut entries) = fs::read_dir(&temp_base_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Check if this directory belongs to an active session + let sessions = sessions.read().await; + if !sessions.contains_key(dir_name) { + // Check if directory is old (>24h) + if let Ok(metadata) = fs::metadata(&path).await + && let Ok(modified) = metadata.modified() + && modified.elapsed().unwrap_or_default() > SESSION_EXPIRATION + { + let _ = fs::remove_dir_all(&path).await; + tracing::info!("🧹 Cleaned orphaned upload dir: {:?}", path); + } + } + } + } + } + } + } + + /// Create a new upload session + pub async fn create_session( + &self, + filename: String, + folder_id: Option, + content_type: String, + total_size: u64, + chunk_size: Option, + ) -> Result { + let upload_id = Uuid::new_v4().to_string(); + let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); + let chunk_count = UploadSession::calculate_chunk_count(total_size, chunk_size); + + // Create temp directory for chunks + let temp_dir = self.temp_base_dir.join(&upload_id); + fs::create_dir_all(&temp_dir) + .await + .map_err(|e| format!("Failed to create temp directory: {}", e))?; + + // Initialize chunk metadata + let mut chunks = Vec::with_capacity(chunk_count); + let mut offset: u64 = 0; + + for i in 0..chunk_count { + let size = if i == chunk_count - 1 { + // Last chunk may be smaller + (total_size - offset) as usize + } else { + chunk_size + }; + + chunks.push(ChunkInfo { + index: i, + offset, + size, + status: ChunkStatus::Pending, + checksum: None, + }); + + offset += size as u64; + } + + let now = Instant::now(); + let session = UploadSession { + id: upload_id.clone(), + filename, + folder_id, + content_type, + total_size, + chunk_size, + chunks, + created_at: now, + last_activity: now, + temp_dir, + bytes_received: 0, + }; + + let expires_at = SESSION_EXPIRATION.as_secs(); + + { + let mut sessions = self.sessions.write().await; + sessions.insert(upload_id.clone(), session); + } + + tracing::info!( + "📤 Created chunked upload session: {} ({} chunks, {} bytes each)", + upload_id, + chunk_count, + chunk_size + ); + + Ok(CreateUploadResponse { + upload_id, + chunk_size, + total_chunks: chunk_count, + expires_at, + }) + } + + /// Upload a single chunk + pub async fn upload_chunk( + &self, + upload_id: &str, + chunk_index: usize, + data: bytes::Bytes, + checksum: Option, + ) -> Result { + // Validate session exists and chunk index is valid + let (chunk_path, expected_size) = { + let sessions = self.sessions.read().await; + let session = sessions + .get(upload_id) + .ok_or_else(|| format!("Upload session not found: {}", upload_id))?; + + if chunk_index >= session.chunks.len() { + return Err(format!( + "Invalid chunk index: {} (max: {})", + chunk_index, + session.chunks.len() - 1 + )); + } + + let chunk = &session.chunks[chunk_index]; + if chunk.status == ChunkStatus::Complete { + return Err(format!("Chunk {} already uploaded", chunk_index)); + } + + ( + session.temp_dir.join(format!("chunk_{:06}", chunk_index)), + chunk.size, + ) + }; + + // Validate chunk size + if data.len() != expected_size { + return Err(format!( + "Invalid chunk size: expected {} bytes, got {} bytes", + expected_size, + data.len() + )); + } + + // Verify checksum if provided + if let Some(ref expected_checksum) = checksum { + let actual_checksum = format!("{:x}", md5::compute(&data)); + if &actual_checksum != expected_checksum { + return Err(format!( + "Checksum mismatch: expected {}, got {}", + expected_checksum, actual_checksum + )); + } + } + + // Write chunk to temp file + let mut file = File::create(&chunk_path) + .await + .map_err(|e| format!("Failed to create chunk file: {}", e))?; + + file.write_all(&data) + .await + .map_err(|e| format!("Failed to write chunk: {}", e))?; + + file.sync_all() + .await + .map_err(|e| format!("Failed to sync chunk: {}", e))?; + + // Update session state + let (bytes_received, progress, is_complete) = { + let mut sessions = self.sessions.write().await; + let session = sessions + .get_mut(upload_id) + .ok_or_else(|| "Session disappeared".to_string())?; + + session.chunks[chunk_index].status = ChunkStatus::Complete; + session.chunks[chunk_index].checksum = checksum; + session.bytes_received += data.len() as u64; + session.last_activity = Instant::now(); + + ( + session.bytes_received, + session.progress(), + session.is_complete(), + ) + }; + + tracing::debug!( + "📦 Chunk {}/{} uploaded for {} ({:.1}% complete)", + chunk_index + 1, + expected_size, + upload_id, + progress * 100.0 + ); + + Ok(ChunkUploadResponse { + chunk_index, + bytes_received, + progress, + is_complete, + }) + } + + /// Get upload status + pub async fn get_status(&self, upload_id: &str) -> Result { + let sessions = self.sessions.read().await; + let session = sessions + .get(upload_id) + .ok_or_else(|| format!("Upload session not found: {}", upload_id))?; + + let completed_chunks = session + .chunks + .iter() + .filter(|c| c.status == ChunkStatus::Complete) + .count(); + + Ok(UploadStatusResponse { + upload_id: session.id.clone(), + filename: session.filename.clone(), + total_size: session.total_size, + bytes_received: session.bytes_received, + progress: session.progress(), + total_chunks: session.chunks.len(), + completed_chunks, + pending_chunks: session.pending_chunks(), + is_complete: session.is_complete(), + }) + } + + /// Assemble chunks into final file and return the path + /// Returns (assembled_file_path, filename, folder_id, content_type, total_size) + pub async fn complete_upload( + &self, + upload_id: &str, + ) -> Result<(PathBuf, String, Option, String, u64), String> { + // Get session and validate completion + let session = { + let sessions = self.sessions.read().await; + let session = sessions + .get(upload_id) + .ok_or_else(|| format!("Upload session not found: {}", upload_id))?; + + if !session.is_complete() { + let pending = session.pending_chunks(); + return Err(format!( + "Upload not complete. Missing chunks: {:?}", + pending + )); + } + + session.clone() + }; + + // Assemble file + let assembled_path = session.temp_dir.join("assembled"); + let mut output = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&assembled_path) + .await + .map_err(|e| format!("Failed to create assembled file: {}", e))?; + + // Append chunks in order + for chunk in &session.chunks { + let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); + let chunk_data = fs::read(&chunk_path) + .await + .map_err(|e| format!("Failed to read chunk {}: {}", chunk.index, e))?; + + output.write_all(&chunk_data).await.map_err(|e| { + format!( + "Failed to write chunk {} to assembled file: {}", + chunk.index, e + ) + })?; + } + + output + .sync_all() + .await + .map_err(|e| format!("Failed to sync assembled file: {}", e))?; + + // Clean up chunk files (keep assembled) + for chunk in &session.chunks { + let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); + let _ = fs::remove_file(&chunk_path).await; + } + + tracing::info!( + "✅ Assembled chunked upload: {} ({} bytes from {} chunks)", + session.filename, + session.total_size, + session.chunks.len() + ); + + Ok(( + assembled_path, + session.filename.clone(), + session.folder_id.clone(), + session.content_type.clone(), + session.total_size, + )) + } + + /// Finalize upload: move assembled file to final location and cleanup session + pub async fn finalize_upload(&self, upload_id: &str) -> Result<(), String> { + let mut sessions = self.sessions.write().await; + if let Some(session) = sessions.remove(upload_id) { + // Clean up entire temp directory + if let Err(e) = fs::remove_dir_all(&session.temp_dir).await { + tracing::warn!("Failed to cleanup upload {}: {}", upload_id, e); + } + } + Ok(()) + } + + /// Cancel an upload and cleanup + pub async fn cancel_upload(&self, upload_id: &str) -> Result<(), String> { + let mut sessions = self.sessions.write().await; + if let Some(session) = sessions.remove(upload_id) { + if let Err(e) = fs::remove_dir_all(&session.temp_dir).await { + tracing::warn!("Failed to cleanup cancelled upload {}: {}", upload_id, e); + } + tracing::info!("❌ Cancelled chunked upload: {}", upload_id); + } + Ok(()) + } + + /// Check if file size qualifies for chunked upload + pub fn should_use_chunked(size: u64) -> bool { + size as usize >= CHUNKED_UPLOAD_THRESHOLD + } + + /// Get active session count (for monitoring) + pub async fn active_sessions(&self) -> usize { + self.sessions.read().await.len() + } +} + +// ─── Port implementation ───────────────────────────────────────────────────── + +#[async_trait] +impl ChunkedUploadPort for ChunkedUploadService { + async fn create_session( + &self, + filename: String, + folder_id: Option, + content_type: String, + total_size: u64, + chunk_size: Option, + ) -> Result { + let resp = self + .create_session(filename, folder_id, content_type, total_size, chunk_size) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?; + Ok(CreateUploadResponseDto { + upload_id: resp.upload_id, + chunk_size: resp.chunk_size, + total_chunks: resp.total_chunks, + expires_at: resp.expires_at, + }) + } + + async fn upload_chunk( + &self, + upload_id: &str, + chunk_index: usize, + data: bytes::Bytes, + checksum: Option, + ) -> Result { + let resp = self + .upload_chunk(upload_id, chunk_index, data, checksum) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))?; + Ok(ChunkUploadResponseDto { + chunk_index: resp.chunk_index, + bytes_received: resp.bytes_received, + progress: resp.progress, + is_complete: resp.is_complete, + }) + } + + async fn get_status(&self, upload_id: &str) -> Result { + let resp = self + .get_status(upload_id) + .await + .map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))?; + Ok(UploadStatusResponseDto { + upload_id: resp.upload_id, + filename: resp.filename, + total_size: resp.total_size, + bytes_received: resp.bytes_received, + progress: resp.progress, + total_chunks: resp.total_chunks, + completed_chunks: resp.completed_chunks, + pending_chunks: resp.pending_chunks, + is_complete: resp.is_complete, + }) + } + + async fn complete_upload( + &self, + upload_id: &str, + ) -> Result<(PathBuf, String, Option, String, u64), DomainError> { + self.complete_upload(upload_id) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) + } + + async fn finalize_upload(&self, upload_id: &str) -> Result<(), DomainError> { + self.finalize_upload(upload_id) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) + } + + async fn cancel_upload(&self, upload_id: &str) -> Result<(), DomainError> { + self.cancel_upload(upload_id) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) + } + + fn should_use_chunked(&self, size: u64) -> bool { + ChunkedUploadService::should_use_chunked(size) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chunk_count_calculation() { + // 10MB file with 5MB chunks = 2 chunks + assert_eq!( + UploadSession::calculate_chunk_count(10 * 1024 * 1024, 5 * 1024 * 1024), + 2 + ); + + // 11MB file with 5MB chunks = 3 chunks + assert_eq!( + UploadSession::calculate_chunk_count(11 * 1024 * 1024, 5 * 1024 * 1024), + 3 + ); + + // 1 byte file = 1 chunk + assert_eq!(UploadSession::calculate_chunk_count(1, 5 * 1024 * 1024), 1); + + // 0 byte file = 1 chunk + assert_eq!(UploadSession::calculate_chunk_count(0, 5 * 1024 * 1024), 1); + } + + #[test] + fn test_should_use_chunked() { + assert!(!ChunkedUploadService::should_use_chunked(9 * 1024 * 1024)); + assert!(ChunkedUploadService::should_use_chunked(10 * 1024 * 1024)); + assert!(ChunkedUploadService::should_use_chunked(100 * 1024 * 1024)); + } +} diff --git a/src/infrastructure/services/compression_service.rs b/src/infrastructure/services/compression_service.rs index 2503cfd8..de816bb6 100644 --- a/src/infrastructure/services/compression_service.rs +++ b/src/infrastructure/services/compression_service.rs @@ -1,17 +1,16 @@ -use std::io::{Read}; -use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; -use futures::{Stream, StreamExt}; -use tracing::error; -use std::io; use flate2::Compression; -use flate2::read::GzEncoder as GzEncoderRead; use flate2::bufread::GzDecoder; +use flate2::read::GzEncoder as GzEncoderRead; +use futures::{Stream, StreamExt}; +use std::io; +use std::io::Read; +use std::sync::Arc; +use tracing::error; use crate::application::ports::compression_ports::{ - CompressionPort, - CompressionLevel as PortCompressionLevel, + CompressionLevel as PortCompressionLevel, CompressionPort, }; use crate::domain::errors::DomainError; use crate::infrastructure::services::buffer_pool::BufferPool; @@ -48,22 +47,27 @@ const COMPRESSION_SIZE_THRESHOLD: u64 = 1024 * 50; // 50KB pub trait CompressionService: Send + Sync { /// Compresses data in memory async fn compress_data(&self, data: &[u8], level: CompressionLevel) -> io::Result>; - + /// Decompresses data in memory async fn decompress_data(&self, compressed_data: &[u8]) -> io::Result>; - + /// Compresses a data stream - fn compress_stream(&self, stream: S, level: CompressionLevel) - -> impl Stream> + Send + fn compress_stream( + &self, + stream: S, + level: CompressionLevel, + ) -> impl Stream> + Send where S: Stream> + Send + 'static + Unpin; - + /// Decompresses a data stream - fn decompress_stream(&self, compressed_stream: S) - -> impl Stream> + Send + fn decompress_stream( + &self, + compressed_stream: S, + ) -> impl Stream> + Send where S: Stream> + Send + 'static + Unpin; - + /// Determines whether a file should be compressed based on its MIME type and size fn should_compress(&self, mime_type: &str, size: u64) -> bool; } @@ -77,11 +81,9 @@ pub struct GzipCompressionService { impl GzipCompressionService { /// Creates a new service instance pub fn new() -> Self { - Self { - buffer_pool: None, - } + Self { buffer_pool: None } } - + /// Creates a new service instance with buffer pool pub fn new_with_buffer_pool(buffer_pool: Arc) -> Self { Self { @@ -98,35 +100,36 @@ impl CompressionService for GzipCompressionService { if let Some(pool) = &self.buffer_pool { // Estimate the compression size (approximately 80% of original for typical cases) let estimated_size = (data.len() as f64 * 0.8) as usize; - + // Get a buffer from the pool let buffer = pool.get_buffer().await; - + // Check if the buffer is large enough if buffer.capacity() >= estimated_size { // Run compression in a worker thread using the buffer let buffer_ptr = Arc::new(tokio::sync::Mutex::new(buffer)); let buffer_clone = buffer_ptr.clone(); - + // Compress data // Clone the data to avoid lifetime issues let data_owned = data.to_vec(); - + let result = tokio::task::spawn_blocking(move || { let mut encoder = GzEncoderRead::new(&data_owned[..], level.into()); - + // Try to lock the mutex (should not fail since we are in a separate thread) let mut buffer_guard = match futures::executor::block_on(buffer_clone.lock()) { buffer => buffer, }; - + // Read directly into the buffer let read_bytes = encoder.read(buffer_guard.as_mut_slice())?; buffer_guard.set_used(read_bytes); - + Ok(()) as io::Result<()> - }).await; - + }) + .await; + // Verify result match result { Ok(Ok(())) => { @@ -135,11 +138,11 @@ impl CompressionService for GzipCompressionService { let cloned_buffer = buffer.clone(); drop(buffer); // Release the mutex first return Ok(cloned_buffer.into_vec()); - }, + } Ok(Err(e)) => { error!("Compression error with buffer pool: {}", e); // Fall back to standard implementation - }, + } Err(e) => { error!("Compression task error with buffer pool: {}", e); // Fall back to standard implementation @@ -147,55 +150,58 @@ impl CompressionService for GzipCompressionService { } } } - + // Standard implementation if there is no buffer pool or the buffer is insufficient // Clone the data to avoid lifetime issues let data_owned = data.to_vec(); - + tokio::task::spawn_blocking(move || { let mut encoder = GzEncoderRead::new(&data_owned[..], level.into()); let mut compressed = Vec::new(); encoder.read_to_end(&mut compressed)?; Ok(compressed) - }).await.unwrap_or_else(|e| { + }) + .await + .unwrap_or_else(|e| { error!("Compression task error: {}", e); Err(io::Error::other(e.to_string())) }) } - + /// Decompresses data in memory async fn decompress_data(&self, compressed_data: &[u8]) -> io::Result> { // If we have a buffer pool, use a borrowed buffer for decompression if let Some(pool) = &self.buffer_pool { // Estimate the decompression size (approximately 5x of compressed for typical cases) let estimated_size = compressed_data.len() * 5; - + // Get a buffer from the pool let buffer = pool.get_buffer().await; - + // Check if the buffer is large enough if buffer.capacity() >= estimated_size { // Clone compressed data to move to the worker let data = compressed_data.to_vec(); let buffer_ptr = Arc::new(tokio::sync::Mutex::new(buffer)); let buffer_clone = buffer_ptr.clone(); - + // Decompress data let result = tokio::task::spawn_blocking(move || { let mut decoder = GzDecoder::new(&data[..]); - + // Try to lock the mutex let mut buffer_guard = match futures::executor::block_on(buffer_clone.lock()) { buffer => buffer, }; - + // Read directly into the buffer let read_bytes = decoder.read(buffer_guard.as_mut_slice())?; buffer_guard.set_used(read_bytes); - + Ok(()) as io::Result<()> - }).await; - + }) + .await; + // Verify result match result { Ok(Ok(())) => { @@ -204,11 +210,11 @@ impl CompressionService for GzipCompressionService { let cloned_buffer = buffer.clone(); drop(buffer); // Release the mutex first return Ok(cloned_buffer.into_vec()); - }, + } Ok(Err(e)) => { error!("Decompression error with buffer pool: {}", e); // Fall back to standard implementation - }, + } Err(e) => { error!("Decompression task error with buffer pool: {}", e); // Fall back to standard implementation @@ -216,7 +222,7 @@ impl CompressionService for GzipCompressionService { } } } - + // Standard implementation if there is no buffer pool or the buffer is insufficient let data = compressed_data.to_vec(); // Clone to move to the worker tokio::task::spawn_blocking(move || { @@ -224,26 +230,31 @@ impl CompressionService for GzipCompressionService { let mut decompressed = Vec::new(); decoder.read_to_end(&mut decompressed)?; Ok(decompressed) - }).await.unwrap_or_else(|e| { + }) + .await + .unwrap_or_else(|e| { error!("Decompression task error: {}", e); Err(io::Error::other(e.to_string())) }) } - + /// Compresses a byte stream - fn compress_stream(&self, stream: S, level: CompressionLevel) - -> impl Stream> + Send + fn compress_stream( + &self, + stream: S, + level: CompressionLevel, + ) -> impl Stream> + Send where - S: Stream> + Send + 'static + Unpin + S: Stream> + Send + 'static + Unpin, { // For now, simplify the implementation to avoid complex pinning issues // This implementation collects all stream data and then compresses it at once // Future optimization would be to implement true streaming compression let compression_level = level; - + Box::pin(async_stream::stream! { let mut data = Vec::new(); - + // Collect all bytes from the stream let mut stream = Box::pin(stream); while let Some(result) = stream.next().await { @@ -257,7 +268,7 @@ impl CompressionService for GzipCompressionService { } } } - + // Compress collected data match CompressionService::compress_data(self, &data, compression_level).await { Ok(compressed) => { @@ -270,19 +281,21 @@ impl CompressionService for GzipCompressionService { } }) } - + /// Decompresses a byte stream - fn decompress_stream(&self, compressed_stream: S) - -> impl Stream> + Send + fn decompress_stream( + &self, + compressed_stream: S, + ) -> impl Stream> + Send where - S: Stream> + Send + 'static + Unpin + S: Stream> + Send + 'static + Unpin, { // For now, simplify the implementation to avoid complex pinning issues // This implementation collects all stream data and then decompresses it at once // Future optimization would be to implement streaming decompression correctly Box::pin(async_stream::stream! { let mut compressed_data = Vec::new(); - + // Collect all bytes from the stream let mut stream = Box::pin(compressed_stream); while let Some(result) = stream.next().await { @@ -296,7 +309,7 @@ impl CompressionService for GzipCompressionService { } } } - + // Decompress collected data match CompressionService::decompress_data(self, &compressed_data).await { Ok(decompressed) => { @@ -309,23 +322,24 @@ impl CompressionService for GzipCompressionService { } }) } - + /// Determines whether a file should be compressed based on its MIME type and size fn should_compress(&self, mime_type: &str, size: u64) -> bool { // Do not compress very small files (overhead) if size < COMPRESSION_SIZE_THRESHOLD { return false; } - + // Do not compress already compressed files if mime_type.starts_with("image/") && !mime_type.contains("svg") - && !mime_type.contains("bmp") { + && !mime_type.contains("bmp") + { return false; } - - if mime_type.starts_with("audio/") - || mime_type.starts_with("video/") + + if mime_type.starts_with("audio/") + || mime_type.starts_with("video/") || mime_type.contains("zip") || mime_type.contains("gzip") || mime_type.contains("compressed") @@ -341,10 +355,11 @@ impl CompressionService for GzipCompressionService { || mime_type.contains("mp3") || mime_type.contains("mp4") || mime_type.contains("ogg") - || mime_type.contains("webm") { + || mime_type.contains("webm") + { return false; } - + // Compress text files, documents, and other compressible types true } @@ -366,12 +381,20 @@ impl From for CompressionLevel { #[async_trait] impl CompressionPort for GzipCompressionService { - async fn compress_data(&self, data: &[u8], level: PortCompressionLevel) -> Result, DomainError> { - CompressionService::compress_data(self, data, level.into()).await.map_err(DomainError::from) + async fn compress_data( + &self, + data: &[u8], + level: PortCompressionLevel, + ) -> Result, DomainError> { + CompressionService::compress_data(self, data, level.into()) + .await + .map_err(DomainError::from) } async fn decompress_data(&self, compressed_data: &[u8]) -> Result, DomainError> { - CompressionService::decompress_data(self, compressed_data).await.map_err(DomainError::from) + CompressionService::decompress_data(self, compressed_data) + .await + .map_err(DomainError::from) } fn should_compress(&self, mime_type: &str, size: u64) -> bool { @@ -383,74 +406,111 @@ impl CompressionPort for GzipCompressionService { mod tests { use super::*; use futures::TryStreamExt; - + #[tokio::test] async fn test_compress_decompress_data() { let service = GzipCompressionService::new(); - + // Test data let data = "Hello, world! ".repeat(1000).into_bytes(); - + // Compress - let compressed = CompressionService::compress_data(&service, &data, CompressionLevel::Default).await.unwrap(); - + let compressed = + CompressionService::compress_data(&service, &data, CompressionLevel::Default) + .await + .unwrap(); + // Verify that compression reduces the size assert!(compressed.len() < data.len()); - + // Decompress - let decompressed = CompressionService::decompress_data(&service, &compressed).await.unwrap(); - + let decompressed = CompressionService::decompress_data(&service, &compressed) + .await + .unwrap(); + // Verify that the original data is recovered correctly assert_eq!(decompressed, data); } - + #[tokio::test] async fn test_compress_decompress_stream() { let service = GzipCompressionService::new(); - + // Create test data let chunks = vec![ Ok(Bytes::from("Hello, ")), Ok(Bytes::from("world! ")), Ok(Bytes::from("This is a test of streaming compression.")), ]; - + // Convert to stream let input_stream = futures::stream::iter(chunks); - + // Compress the stream let compressed_stream = service.compress_stream(input_stream, CompressionLevel::Default); - + // Collect the compressed bytes let compressed_bytes = compressed_stream .try_fold(Vec::new(), |mut acc, chunk| async move { acc.extend_from_slice(&chunk); Ok(acc) - }).await.unwrap(); - + }) + .await + .unwrap(); + // Decompress the data - let decompressed = CompressionService::decompress_data(&service, &compressed_bytes).await.unwrap(); - + let decompressed = CompressionService::decompress_data(&service, &compressed_bytes) + .await + .unwrap(); + // Verify result let expected = "Hello, world! This is a test of streaming compression."; assert_eq!(String::from_utf8(decompressed).unwrap(), expected); } - + #[test] fn test_should_compress() { let service = GzipCompressionService::new(); - + // Cases that should not be compressed - assert!(!CompressionService::should_compress(&service, "image/jpeg", 100 * 1024)); - assert!(!CompressionService::should_compress(&service, "video/mp4", 10 * 1024 * 1024)); - assert!(!CompressionService::should_compress(&service, "application/zip", 5 * 1024 * 1024)); - + assert!(!CompressionService::should_compress( + &service, + "image/jpeg", + 100 * 1024 + )); + assert!(!CompressionService::should_compress( + &service, + "video/mp4", + 10 * 1024 * 1024 + )); + assert!(!CompressionService::should_compress( + &service, + "application/zip", + 5 * 1024 * 1024 + )); + // Cases that should be compressed - assert!(CompressionService::should_compress(&service, "text/html", 100 * 1024)); - assert!(CompressionService::should_compress(&service, "application/json", 200 * 1024)); - assert!(CompressionService::should_compress(&service, "text/plain", 1024 * 1024)); - + assert!(CompressionService::should_compress( + &service, + "text/html", + 100 * 1024 + )); + assert!(CompressionService::should_compress( + &service, + "application/json", + 200 * 1024 + )); + assert!(CompressionService::should_compress( + &service, + "text/plain", + 1024 * 1024 + )); + // Small files should not be compressed regardless of type - assert!(!CompressionService::should_compress(&service, "text/html", 10 * 1024)); + assert!(!CompressionService::should_compress( + &service, + "text/html", + 10 * 1024 + )); } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 964c45c4..0bddb276 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -1,848 +1,901 @@ -//! Content-Addressable Storage with Deduplication -//! -//! Implements hash-based deduplication to eliminate redundant file storage. -//! Files are stored by their SHA-256 hash, and multiple references can point -//! to the same physical blob. -//! -//! Architecture: -//! ```text -//! ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -//! │ User Files │────▶│ Dedup Index │────▶│ Blob Store │ -//! │ (references) │ │ (hash→metadata) │ │ (actual data) │ -//! └─────────────────┘ └─────────────────┘ └─────────────────┘ -//! ``` -//! -//! Benefits: -//! - 30-50% storage reduction typical -//! - Faster uploads for existing content (instant dedup) -//! - Efficient backups - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::fs::{self, File}; -use tokio::io::{AsyncReadExt, BufReader}; -use tokio::sync::RwLock; -use sha2::{Sha256, Digest}; -use bytes::Bytes; -use serde::{Deserialize, Serialize}; -use async_trait::async_trait; - -use crate::application::ports::dedup_ports::{ - DedupPort, - BlobMetadataDto, - DedupResultDto, - DedupStatsDto, -}; -use crate::domain::errors::{DomainError, ErrorKind}; - -/// Chunk size for streaming hash calculation (256KB) -const HASH_CHUNK_SIZE: usize = 256 * 1024; - -/// Minimum file size for deduplication (skip tiny files) -const MIN_DEDUP_SIZE: u64 = 4096; // 4KB - -/// Blob metadata stored in the dedup index -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlobMetadata { - /// SHA-256 hash of the content - pub hash: String, - /// Size in bytes - pub size: u64, - /// Number of references to this blob - pub ref_count: u32, - /// When the blob was first stored - pub created_at: chrono::DateTime, - /// Original content type (for serving) - pub content_type: Option, -} - -/// Result of a dedup operation -#[derive(Debug, Clone)] -pub enum DedupResult { - /// New content was stored - NewBlob { - hash: String, - size: u64, - blob_path: PathBuf, - }, - /// Content already existed, reference added - ExistingBlob { - hash: String, - size: u64, - blob_path: PathBuf, - saved_bytes: u64, - }, -} - -impl DedupResult { - pub fn hash(&self) -> &str { - match self { - DedupResult::NewBlob { hash, .. } => hash, - DedupResult::ExistingBlob { hash, .. } => hash, - } - } - - pub fn size(&self) -> u64 { - match self { - DedupResult::NewBlob { size, .. } => *size, - DedupResult::ExistingBlob { size, .. } => *size, - } - } - - pub fn blob_path(&self) -> &Path { - match self { - DedupResult::NewBlob { blob_path, .. } => blob_path, - DedupResult::ExistingBlob { blob_path, .. } => blob_path, - } - } - - pub fn was_deduplicated(&self) -> bool { - matches!(self, DedupResult::ExistingBlob { .. }) - } -} - -/// Statistics for the dedup service -#[derive(Debug, Clone, Default, Serialize)] -pub struct DedupStats { - /// Total number of unique blobs - pub total_blobs: u64, - /// Total bytes stored (actual disk usage) - pub total_bytes_stored: u64, - /// Total bytes referenced (logical size) - pub total_bytes_referenced: u64, - /// Bytes saved through deduplication - pub bytes_saved: u64, - /// Number of dedup hits - pub dedup_hits: u64, - /// Deduplication ratio (referenced / stored) - pub dedup_ratio: f64, -} - -/// Content-Addressable Storage Service -pub struct DedupService { - /// Root directory for blob storage - blob_root: PathBuf, - /// Root directory for temporary files during upload - temp_root: PathBuf, - /// In-memory index of blobs (hash -> metadata) - index: Arc>>, - /// Path to persistent index file - index_path: PathBuf, - /// Statistics - stats: Arc>, -} - -impl DedupService { - /// Create a new dedup service - pub fn new(storage_root: &Path) -> Self { - let blob_root = storage_root.join(".blobs"); - let temp_root = storage_root.join(".dedup_temp"); - let index_path = storage_root.join(".dedup_index.json"); - - Self { - blob_root, - temp_root, - index: Arc::new(RwLock::new(HashMap::new())), - index_path, - stats: Arc::new(RwLock::new(DedupStats::default())), - } - } - - /// Initialize the service (create directories, load index) - pub async fn initialize(&self) -> std::io::Result<()> { - // Create directories - fs::create_dir_all(&self.blob_root).await?; - fs::create_dir_all(&self.temp_root).await?; - - // Create hash prefix directories (00-ff) - for i in 0..=255u8 { - let prefix = format!("{:02x}", i); - fs::create_dir_all(self.blob_root.join(&prefix)).await?; - } - - // Load existing index - self.load_index().await?; - - tracing::info!( - "🔗 Dedup service initialized: {} blobs, {} bytes stored", - self.stats.read().await.total_blobs, - self.stats.read().await.total_bytes_stored - ); - - Ok(()) - } - - /// Load index from disk - async fn load_index(&self) -> std::io::Result<()> { - if !self.index_path.exists() { - return Ok(()); - } - - let content = fs::read_to_string(&self.index_path).await?; - let entries: Vec = serde_json::from_str(&content) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - - let mut index = self.index.write().await; - let mut stats = self.stats.write().await; - - for entry in entries { - stats.total_blobs += 1; - stats.total_bytes_stored += entry.size; - stats.total_bytes_referenced += entry.size * entry.ref_count as u64; - - index.insert(entry.hash.clone(), entry); - } - - stats.bytes_saved = stats.total_bytes_referenced.saturating_sub(stats.total_bytes_stored); - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - - Ok(()) - } - - /// Save index to disk - async fn save_index(&self) -> std::io::Result<()> { - let index = self.index.read().await; - let entries: Vec<&BlobMetadata> = index.values().collect(); - let content = serde_json::to_string_pretty(&entries) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - - // Write atomically - let temp_path = self.index_path.with_extension("json.tmp"); - fs::write(&temp_path, content).await?; - fs::rename(&temp_path, &self.index_path).await?; - - Ok(()) - } - - /// Get the blob path for a given hash - pub fn blob_path(&self, hash: &str) -> PathBuf { - // Use first 2 chars as directory prefix for better filesystem distribution - let prefix = &hash[0..2]; - self.blob_root.join(prefix).join(format!("{}.blob", hash)) - } - - /// Calculate SHA-256 hash of content - pub fn hash_bytes(content: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(content); - hex::encode(hasher.finalize()) - } - - /// Calculate SHA-256 hash of a file (streaming) - pub async fn hash_file(path: &Path) -> std::io::Result { - let file = File::open(path).await?; - let mut reader = BufReader::with_capacity(HASH_CHUNK_SIZE, file); - let mut hasher = Sha256::new(); - let mut buffer = vec![0u8; HASH_CHUNK_SIZE]; - - loop { - let bytes_read = reader.read(&mut buffer).await?; - if bytes_read == 0 { - break; - } - hasher.update(&buffer[..bytes_read]); - } - - Ok(hex::encode(hasher.finalize())) - } - - /// Check if a blob exists - pub async fn blob_exists(&self, hash: &str) -> bool { - let index = self.index.read().await; - index.contains_key(hash) - } - - /// Get blob metadata - pub async fn get_blob_metadata(&self, hash: &str) -> Option { - let index = self.index.read().await; - index.get(hash).cloned() - } - - /// Store content with deduplication (from bytes) - pub async fn store_bytes( - &self, - content: &[u8], - content_type: Option, - ) -> Result { - let size = content.len() as u64; - - // Skip dedup for tiny files - if size < MIN_DEDUP_SIZE { - return self.store_new_blob_from_bytes(content, content_type).await; - } - - // Calculate hash - let hash = Self::hash_bytes(content); - - // Check if already exists - if self.blob_exists(&hash).await { - // Increment reference count - self.increment_ref_count(&hash).await?; - - let blob_path = self.blob_path(&hash); - - // Update stats - { - let mut stats = self.stats.write().await; - stats.dedup_hits += 1; - stats.bytes_saved += size; - stats.total_bytes_referenced += size; - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - } - - tracing::info!("🔗 DEDUP HIT: {} ({} bytes saved)", &hash[..12], size); - - return Ok(DedupResult::ExistingBlob { - hash, - size, - blob_path, - saved_bytes: size, - }); - } - - // Store new blob - self.store_new_blob_from_bytes_with_hash(content, content_type, hash).await - } - - /// Store new blob from bytes (no dedup check) - async fn store_new_blob_from_bytes( - &self, - content: &[u8], - content_type: Option, - ) -> Result { - let hash = Self::hash_bytes(content); - self.store_new_blob_from_bytes_with_hash(content, content_type, hash).await - } - - /// Store new blob from bytes with known hash - async fn store_new_blob_from_bytes_with_hash( - &self, - content: &[u8], - content_type: Option, - hash: String, - ) -> Result { - let size = content.len() as u64; - let blob_path = self.blob_path(&hash); - - // Ensure parent directory exists - if let Some(parent) = blob_path.parent() { - fs::create_dir_all(parent).await - .map_err(|e| format!("Failed to create blob directory: {}", e))?; - } - - // Write blob atomically - let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4())); - fs::write(&temp_path, content).await - .map_err(|e| format!("Failed to write temp blob: {}", e))?; - - fs::rename(&temp_path, &blob_path).await - .map_err(|e| format!("Failed to move blob to final location: {}", e))?; - - // Register in index - let metadata = BlobMetadata { - hash: hash.clone(), - size, - ref_count: 1, - created_at: chrono::Utc::now(), - content_type, - }; - - { - let mut index = self.index.write().await; - index.insert(hash.clone(), metadata); - } - - // Update stats - { - let mut stats = self.stats.write().await; - stats.total_blobs += 1; - stats.total_bytes_stored += size; - stats.total_bytes_referenced += size; - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - } - - // Save index periodically (every 100 new blobs or async) - let save_index = self.stats.read().await.total_blobs % 100 == 0; - if save_index { - let _ = self.save_index().await; - } - - tracing::info!("💾 NEW BLOB: {} ({} bytes)", &hash[..12], size); - - Ok(DedupResult::NewBlob { - hash, - size, - blob_path, - }) - } - - /// Store content with deduplication (streaming from file) - pub async fn store_from_file( - &self, - source_path: &Path, - content_type: Option, - ) -> Result { - let file_size = fs::metadata(source_path).await - .map_err(|e| format!("Failed to get file metadata: {}", e))? - .len(); - - // Skip dedup for tiny files - if file_size < MIN_DEDUP_SIZE { - let content = fs::read(source_path).await - .map_err(|e| format!("Failed to read file: {}", e))?; - return self.store_new_blob_from_bytes(&content, content_type).await; - } - - // Calculate hash (streaming) - let hash = Self::hash_file(source_path).await - .map_err(|e| format!("Failed to hash file: {}", e))?; - - // Check if already exists - if self.blob_exists(&hash).await { - // Increment reference count - self.increment_ref_count(&hash).await?; - - let blob_path = self.blob_path(&hash); - - // Update stats - { - let mut stats = self.stats.write().await; - stats.dedup_hits += 1; - stats.bytes_saved += file_size; - stats.total_bytes_referenced += file_size; - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - } - - // Delete source file since we don't need it - let _ = fs::remove_file(source_path).await; - - tracing::info!("🔗 DEDUP HIT (file): {} ({} bytes saved)", &hash[..12], file_size); - - return Ok(DedupResult::ExistingBlob { - hash, - size: file_size, - blob_path, - saved_bytes: file_size, - }); - } - - // Move file to blob store - let blob_path = self.blob_path(&hash); - - if let Some(parent) = blob_path.parent() { - fs::create_dir_all(parent).await - .map_err(|e| format!("Failed to create blob directory: {}", e))?; - } - - fs::rename(source_path, &blob_path).await - .map_err(|e| format!("Failed to move file to blob store: {}", e))?; - - // Register in index - let metadata = BlobMetadata { - hash: hash.clone(), - size: file_size, - ref_count: 1, - created_at: chrono::Utc::now(), - content_type, - }; - - { - let mut index = self.index.write().await; - index.insert(hash.clone(), metadata); - } - - // Update stats - { - let mut stats = self.stats.write().await; - stats.total_blobs += 1; - stats.total_bytes_stored += file_size; - stats.total_bytes_referenced += file_size; - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - } - - tracing::info!("💾 NEW BLOB (file): {} ({} bytes)", &hash[..12], file_size); - - Ok(DedupResult::NewBlob { - hash, - size: file_size, - blob_path, - }) - } - - /// Increment reference count for a blob - async fn increment_ref_count(&self, hash: &str) -> Result<(), String> { - let mut index = self.index.write().await; - - if let Some(metadata) = index.get_mut(hash) { - metadata.ref_count += 1; - Ok(()) - } else { - Err(format!("Blob not found: {}", hash)) - } - } - - /// Add a reference to a blob (used when creating file references) - pub async fn add_reference(&self, hash: &str) -> Result<(), String> { - self.increment_ref_count(hash).await?; - - // Update stats - if let Some(metadata) = self.get_blob_metadata(hash).await { - let mut stats = self.stats.write().await; - stats.total_bytes_referenced += metadata.size; - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - } - - Ok(()) - } - - /// Remove a reference to a blob, delete blob if ref_count reaches 0 - pub async fn remove_reference(&self, hash: &str) -> Result { - let should_delete = { - let mut index = self.index.write().await; - - if let Some(metadata) = index.get_mut(hash) { - metadata.ref_count = metadata.ref_count.saturating_sub(1); - - // Update stats - { - let mut stats = self.stats.write().await; - stats.total_bytes_referenced = stats.total_bytes_referenced.saturating_sub(metadata.size); - stats.bytes_saved = stats.bytes_saved.saturating_sub(metadata.size); - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } - } - - metadata.ref_count == 0 - } else { - return Ok(false); - } - }; - - if should_delete { - // Remove from index - let removed_metadata = { - let mut index = self.index.write().await; - index.remove(hash) - }; - - if let Some(metadata) = removed_metadata { - // Delete blob file - let blob_path = self.blob_path(hash); - if let Err(e) = fs::remove_file(&blob_path).await { - tracing::warn!("Failed to delete blob {}: {}", hash, e); - } - - // Update stats - { - let mut stats = self.stats.write().await; - stats.total_blobs = stats.total_blobs.saturating_sub(1); - stats.total_bytes_stored = stats.total_bytes_stored.saturating_sub(metadata.size); - if stats.total_bytes_stored > 0 { - stats.dedup_ratio = stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; - } else { - stats.dedup_ratio = 1.0; - } - } - - tracing::info!("🗑️ BLOB DELETED: {} (no more references)", &hash[..12]); - } - - Ok(true) - } else { - tracing::debug!("📎 Reference removed from blob {}", &hash[..12]); - Ok(false) - } - } - - /// Read blob content - pub async fn read_blob(&self, hash: &str) -> Result, String> { - let blob_path = self.blob_path(hash); - - if !blob_path.exists() { - return Err(format!("Blob not found: {}", hash)); - } - - fs::read(&blob_path).await - .map_err(|e| format!("Failed to read blob: {}", e)) - } - - /// Read blob as Bytes - pub async fn read_blob_bytes(&self, hash: &str) -> Result { - self.read_blob(hash).await.map(Bytes::from) - } - - /// Get statistics - pub async fn get_stats(&self) -> DedupStats { - self.stats.read().await.clone() - } - - /// Flush index to disk - pub async fn flush(&self) -> std::io::Result<()> { - self.save_index().await - } - - /// Verify integrity of all blobs - pub async fn verify_integrity(&self) -> Result, String> { - let mut corrupted = Vec::new(); - let index = self.index.read().await; - - for (hash, metadata) in index.iter() { - let blob_path = self.blob_path(hash); - - // Check file exists - if !blob_path.exists() { - corrupted.push(format!("{}: file missing", hash)); - continue; - } - - // Verify hash - match Self::hash_file(&blob_path).await { - Ok(actual_hash) => { - if actual_hash != *hash { - corrupted.push(format!("{}: hash mismatch (actual: {})", hash, actual_hash)); - } - }, - Err(e) => { - corrupted.push(format!("{}: read error ({})", hash, e)); - } - } - - // Check size - if let Ok(file_meta) = fs::metadata(&blob_path).await - && file_meta.len() != metadata.size { - corrupted.push(format!( - "{}: size mismatch (expected: {}, actual: {})", - hash, metadata.size, file_meta.len() - )); - } - } - - if corrupted.is_empty() { - tracing::info!("✅ Integrity check passed for {} blobs", index.len()); - } else { - tracing::warn!("⚠️ Integrity check found {} issues", corrupted.len()); - } - - Ok(corrupted) - } - - /// Garbage collect orphaned blobs (blobs with ref_count=0) - pub async fn garbage_collect(&self) -> Result<(u64, u64), String> { - let orphans: Vec<(String, u64)> = { - let index = self.index.read().await; - index.iter() - .filter(|(_, m)| m.ref_count == 0) - .map(|(h, m)| (h.clone(), m.size)) - .collect() - }; - - let mut deleted_count = 0u64; - let mut deleted_bytes = 0u64; - - for (hash, size) in orphans { - if self.remove_reference(&hash).await.is_ok() { - deleted_count += 1; - deleted_bytes += size; - } - } - - if deleted_count > 0 { - let _ = self.save_index().await; - tracing::info!( - "🧹 Garbage collected {} blobs ({} bytes)", - deleted_count, deleted_bytes - ); - } - - Ok((deleted_count, deleted_bytes)) - } -} - -// ─── Port implementation ───────────────────────────────────────────────────── - -/// Convert infra DedupResult to port DedupResultDto. -impl From for DedupResultDto { - fn from(result: DedupResult) -> Self { - match result { - DedupResult::NewBlob { hash, size, blob_path } => { - DedupResultDto::NewBlob { hash, size, blob_path } - } - DedupResult::ExistingBlob { hash, size, blob_path, saved_bytes } => { - DedupResultDto::ExistingBlob { hash, size, blob_path, saved_bytes } - } - } - } -} - -/// Convert infra BlobMetadata to port BlobMetadataDto. -impl From for BlobMetadataDto { - fn from(m: BlobMetadata) -> Self { - BlobMetadataDto { - hash: m.hash, - size: m.size, - ref_count: m.ref_count, - content_type: m.content_type, - } - } -} - -/// Convert infra DedupStats to port DedupStatsDto. -impl From for DedupStatsDto { - fn from(s: DedupStats) -> Self { - DedupStatsDto { - total_blobs: s.total_blobs, - total_bytes_stored: s.total_bytes_stored, - total_bytes_referenced: s.total_bytes_referenced, - bytes_saved: s.bytes_saved, - dedup_hits: s.dedup_hits, - dedup_ratio: s.dedup_ratio, - } - } -} - -#[async_trait] -impl DedupPort for DedupService { - async fn store_bytes( - &self, - content: &[u8], - content_type: Option, - ) -> Result { - self.store_bytes(content, content_type).await - .map(Into::into) - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) - } - - async fn store_from_file( - &self, - source_path: &Path, - content_type: Option, - ) -> Result { - self.store_from_file(source_path, content_type).await - .map(Into::into) - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) - } - - async fn blob_exists(&self, hash: &str) -> bool { - self.blob_exists(hash).await - } - - async fn get_blob_metadata(&self, hash: &str) -> Option { - self.get_blob_metadata(hash).await.map(Into::into) - } - - async fn read_blob(&self, hash: &str) -> Result, DomainError> { - self.read_blob(hash).await - .map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e)) - } - - async fn read_blob_bytes(&self, hash: &str) -> Result { - self.read_blob_bytes(hash).await - .map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e)) - } - - async fn add_reference(&self, hash: &str) -> Result<(), DomainError> { - self.add_reference(hash).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e)) - } - - async fn remove_reference(&self, hash: &str) -> Result { - self.remove_reference(hash).await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e)) - } - - fn hash_bytes(&self, content: &[u8]) -> String { - DedupService::hash_bytes(content) - } - - async fn hash_file(&self, path: &Path) -> Result { - DedupService::hash_file(path).await.map_err(DomainError::from) - } - - async fn get_stats(&self) -> DedupStatsDto { - self.get_stats().await.into() - } - - async fn flush(&self) -> Result<(), DomainError> { - self.flush().await.map_err(DomainError::from) - } - - async fn verify_integrity(&self) -> Result, DomainError> { - self.verify_integrity().await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_dedup_identical_content() { - let temp_dir = TempDir::new().unwrap(); - let service = DedupService::new(temp_dir.path()); - service.initialize().await.unwrap(); - - // Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in - let content = &b"Hello, World! This is test content for dedup. ".repeat(100); - - // First store - let result1 = service.store_bytes(content, None).await.unwrap(); - assert!(!result1.was_deduplicated()); - - // Second store (same content) - let result2 = service.store_bytes(content, None).await.unwrap(); - assert!(result2.was_deduplicated()); - assert_eq!(result1.hash(), result2.hash()); - - // Check stats - let stats = service.get_stats().await; - assert_eq!(stats.total_blobs, 1); - assert_eq!(stats.dedup_hits, 1); - } - - #[tokio::test] - async fn test_reference_counting() { - let temp_dir = TempDir::new().unwrap(); - let service = DedupService::new(temp_dir.path()); - service.initialize().await.unwrap(); - - // Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in - let content = &b"Test content for reference counting. ".repeat(120); - - // Store twice - let result1 = service.store_bytes(content, None).await.unwrap(); - let _result2 = service.store_bytes(content, None).await.unwrap(); - - let hash = result1.hash().to_string(); - - // Check ref count - let metadata = service.get_blob_metadata(&hash).await.unwrap(); - assert_eq!(metadata.ref_count, 2); - - // Remove one reference - let deleted = service.remove_reference(&hash).await.unwrap(); - assert!(!deleted); - - // Remove second reference (should delete) - let deleted = service.remove_reference(&hash).await.unwrap(); - assert!(deleted); - - // Blob should be gone - assert!(!service.blob_exists(&hash).await); - } -} +//! Content-Addressable Storage with Deduplication +//! +//! Implements hash-based deduplication to eliminate redundant file storage. +//! Files are stored by their SHA-256 hash, and multiple references can point +//! to the same physical blob. +//! +//! Architecture: +//! ```text +//! ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +//! │ User Files │────▶│ Dedup Index │────▶│ Blob Store │ +//! │ (references) │ │ (hash→metadata) │ │ (actual data) │ +//! └─────────────────┘ └─────────────────┘ └─────────────────┘ +//! ``` +//! +//! Benefits: +//! - 30-50% storage reduction typical +//! - Faster uploads for existing content (instant dedup) +//! - Efficient backups + +use async_trait::async_trait; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs::{self, File}; +use tokio::io::{AsyncReadExt, BufReader}; +use tokio::sync::RwLock; + +use crate::application::ports::dedup_ports::{ + BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; + +/// Chunk size for streaming hash calculation (256KB) +const HASH_CHUNK_SIZE: usize = 256 * 1024; + +/// Minimum file size for deduplication (skip tiny files) +const MIN_DEDUP_SIZE: u64 = 4096; // 4KB + +/// Blob metadata stored in the dedup index +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobMetadata { + /// SHA-256 hash of the content + pub hash: String, + /// Size in bytes + pub size: u64, + /// Number of references to this blob + pub ref_count: u32, + /// When the blob was first stored + pub created_at: chrono::DateTime, + /// Original content type (for serving) + pub content_type: Option, +} + +/// Result of a dedup operation +#[derive(Debug, Clone)] +pub enum DedupResult { + /// New content was stored + NewBlob { + hash: String, + size: u64, + blob_path: PathBuf, + }, + /// Content already existed, reference added + ExistingBlob { + hash: String, + size: u64, + blob_path: PathBuf, + saved_bytes: u64, + }, +} + +impl DedupResult { + pub fn hash(&self) -> &str { + match self { + DedupResult::NewBlob { hash, .. } => hash, + DedupResult::ExistingBlob { hash, .. } => hash, + } + } + + pub fn size(&self) -> u64 { + match self { + DedupResult::NewBlob { size, .. } => *size, + DedupResult::ExistingBlob { size, .. } => *size, + } + } + + pub fn blob_path(&self) -> &Path { + match self { + DedupResult::NewBlob { blob_path, .. } => blob_path, + DedupResult::ExistingBlob { blob_path, .. } => blob_path, + } + } + + pub fn was_deduplicated(&self) -> bool { + matches!(self, DedupResult::ExistingBlob { .. }) + } +} + +/// Statistics for the dedup service +#[derive(Debug, Clone, Default, Serialize)] +pub struct DedupStats { + /// Total number of unique blobs + pub total_blobs: u64, + /// Total bytes stored (actual disk usage) + pub total_bytes_stored: u64, + /// Total bytes referenced (logical size) + pub total_bytes_referenced: u64, + /// Bytes saved through deduplication + pub bytes_saved: u64, + /// Number of dedup hits + pub dedup_hits: u64, + /// Deduplication ratio (referenced / stored) + pub dedup_ratio: f64, +} + +/// Content-Addressable Storage Service +pub struct DedupService { + /// Root directory for blob storage + blob_root: PathBuf, + /// Root directory for temporary files during upload + temp_root: PathBuf, + /// In-memory index of blobs (hash -> metadata) + index: Arc>>, + /// Path to persistent index file + index_path: PathBuf, + /// Statistics + stats: Arc>, +} + +impl DedupService { + /// Create a new dedup service + pub fn new(storage_root: &Path) -> Self { + let blob_root = storage_root.join(".blobs"); + let temp_root = storage_root.join(".dedup_temp"); + let index_path = storage_root.join(".dedup_index.json"); + + Self { + blob_root, + temp_root, + index: Arc::new(RwLock::new(HashMap::new())), + index_path, + stats: Arc::new(RwLock::new(DedupStats::default())), + } + } + + /// Initialize the service (create directories, load index) + pub async fn initialize(&self) -> std::io::Result<()> { + // Create directories + fs::create_dir_all(&self.blob_root).await?; + fs::create_dir_all(&self.temp_root).await?; + + // Create hash prefix directories (00-ff) + for i in 0..=255u8 { + let prefix = format!("{:02x}", i); + fs::create_dir_all(self.blob_root.join(&prefix)).await?; + } + + // Load existing index + self.load_index().await?; + + tracing::info!( + "🔗 Dedup service initialized: {} blobs, {} bytes stored", + self.stats.read().await.total_blobs, + self.stats.read().await.total_bytes_stored + ); + + Ok(()) + } + + /// Load index from disk + async fn load_index(&self) -> std::io::Result<()> { + if !self.index_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&self.index_path).await?; + let entries: Vec = serde_json::from_str(&content) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let mut index = self.index.write().await; + let mut stats = self.stats.write().await; + + for entry in entries { + stats.total_blobs += 1; + stats.total_bytes_stored += entry.size; + stats.total_bytes_referenced += entry.size * entry.ref_count as u64; + + index.insert(entry.hash.clone(), entry); + } + + stats.bytes_saved = stats + .total_bytes_referenced + .saturating_sub(stats.total_bytes_stored); + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + + Ok(()) + } + + /// Save index to disk + async fn save_index(&self) -> std::io::Result<()> { + let index = self.index.read().await; + let entries: Vec<&BlobMetadata> = index.values().collect(); + let content = serde_json::to_string_pretty(&entries) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + // Write atomically + let temp_path = self.index_path.with_extension("json.tmp"); + fs::write(&temp_path, content).await?; + fs::rename(&temp_path, &self.index_path).await?; + + Ok(()) + } + + /// Get the blob path for a given hash + pub fn blob_path(&self, hash: &str) -> PathBuf { + // Use first 2 chars as directory prefix for better filesystem distribution + let prefix = &hash[0..2]; + self.blob_root.join(prefix).join(format!("{}.blob", hash)) + } + + /// Calculate SHA-256 hash of content + pub fn hash_bytes(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(content); + hex::encode(hasher.finalize()) + } + + /// Calculate SHA-256 hash of a file (streaming) + pub async fn hash_file(path: &Path) -> std::io::Result { + let file = File::open(path).await?; + let mut reader = BufReader::with_capacity(HASH_CHUNK_SIZE, file); + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; HASH_CHUNK_SIZE]; + + loop { + let bytes_read = reader.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + Ok(hex::encode(hasher.finalize())) + } + + /// Check if a blob exists + pub async fn blob_exists(&self, hash: &str) -> bool { + let index = self.index.read().await; + index.contains_key(hash) + } + + /// Get blob metadata + pub async fn get_blob_metadata(&self, hash: &str) -> Option { + let index = self.index.read().await; + index.get(hash).cloned() + } + + /// Store content with deduplication (from bytes) + pub async fn store_bytes( + &self, + content: &[u8], + content_type: Option, + ) -> Result { + let size = content.len() as u64; + + // Skip dedup for tiny files + if size < MIN_DEDUP_SIZE { + return self.store_new_blob_from_bytes(content, content_type).await; + } + + // Calculate hash + let hash = Self::hash_bytes(content); + + // Check if already exists + if self.blob_exists(&hash).await { + // Increment reference count + self.increment_ref_count(&hash).await?; + + let blob_path = self.blob_path(&hash); + + // Update stats + { + let mut stats = self.stats.write().await; + stats.dedup_hits += 1; + stats.bytes_saved += size; + stats.total_bytes_referenced += size; + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + } + + tracing::info!("🔗 DEDUP HIT: {} ({} bytes saved)", &hash[..12], size); + + return Ok(DedupResult::ExistingBlob { + hash, + size, + blob_path, + saved_bytes: size, + }); + } + + // Store new blob + self.store_new_blob_from_bytes_with_hash(content, content_type, hash) + .await + } + + /// Store new blob from bytes (no dedup check) + async fn store_new_blob_from_bytes( + &self, + content: &[u8], + content_type: Option, + ) -> Result { + let hash = Self::hash_bytes(content); + self.store_new_blob_from_bytes_with_hash(content, content_type, hash) + .await + } + + /// Store new blob from bytes with known hash + async fn store_new_blob_from_bytes_with_hash( + &self, + content: &[u8], + content_type: Option, + hash: String, + ) -> Result { + let size = content.len() as u64; + let blob_path = self.blob_path(&hash); + + // Ensure parent directory exists + if let Some(parent) = blob_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create blob directory: {}", e))?; + } + + // Write blob atomically + let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4())); + fs::write(&temp_path, content) + .await + .map_err(|e| format!("Failed to write temp blob: {}", e))?; + + fs::rename(&temp_path, &blob_path) + .await + .map_err(|e| format!("Failed to move blob to final location: {}", e))?; + + // Register in index + let metadata = BlobMetadata { + hash: hash.clone(), + size, + ref_count: 1, + created_at: chrono::Utc::now(), + content_type, + }; + + { + let mut index = self.index.write().await; + index.insert(hash.clone(), metadata); + } + + // Update stats + { + let mut stats = self.stats.write().await; + stats.total_blobs += 1; + stats.total_bytes_stored += size; + stats.total_bytes_referenced += size; + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + } + + // Save index periodically (every 100 new blobs or async) + let save_index = self.stats.read().await.total_blobs % 100 == 0; + if save_index { + let _ = self.save_index().await; + } + + tracing::info!("💾 NEW BLOB: {} ({} bytes)", &hash[..12], size); + + Ok(DedupResult::NewBlob { + hash, + size, + blob_path, + }) + } + + /// Store content with deduplication (streaming from file) + pub async fn store_from_file( + &self, + source_path: &Path, + content_type: Option, + ) -> Result { + let file_size = fs::metadata(source_path) + .await + .map_err(|e| format!("Failed to get file metadata: {}", e))? + .len(); + + // Skip dedup for tiny files + if file_size < MIN_DEDUP_SIZE { + let content = fs::read(source_path) + .await + .map_err(|e| format!("Failed to read file: {}", e))?; + return self.store_new_blob_from_bytes(&content, content_type).await; + } + + // Calculate hash (streaming) + let hash = Self::hash_file(source_path) + .await + .map_err(|e| format!("Failed to hash file: {}", e))?; + + // Check if already exists + if self.blob_exists(&hash).await { + // Increment reference count + self.increment_ref_count(&hash).await?; + + let blob_path = self.blob_path(&hash); + + // Update stats + { + let mut stats = self.stats.write().await; + stats.dedup_hits += 1; + stats.bytes_saved += file_size; + stats.total_bytes_referenced += file_size; + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + } + + // Delete source file since we don't need it + let _ = fs::remove_file(source_path).await; + + tracing::info!( + "🔗 DEDUP HIT (file): {} ({} bytes saved)", + &hash[..12], + file_size + ); + + return Ok(DedupResult::ExistingBlob { + hash, + size: file_size, + blob_path, + saved_bytes: file_size, + }); + } + + // Move file to blob store + let blob_path = self.blob_path(&hash); + + if let Some(parent) = blob_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create blob directory: {}", e))?; + } + + fs::rename(source_path, &blob_path) + .await + .map_err(|e| format!("Failed to move file to blob store: {}", e))?; + + // Register in index + let metadata = BlobMetadata { + hash: hash.clone(), + size: file_size, + ref_count: 1, + created_at: chrono::Utc::now(), + content_type, + }; + + { + let mut index = self.index.write().await; + index.insert(hash.clone(), metadata); + } + + // Update stats + { + let mut stats = self.stats.write().await; + stats.total_blobs += 1; + stats.total_bytes_stored += file_size; + stats.total_bytes_referenced += file_size; + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + } + + tracing::info!("💾 NEW BLOB (file): {} ({} bytes)", &hash[..12], file_size); + + Ok(DedupResult::NewBlob { + hash, + size: file_size, + blob_path, + }) + } + + /// Increment reference count for a blob + async fn increment_ref_count(&self, hash: &str) -> Result<(), String> { + let mut index = self.index.write().await; + + if let Some(metadata) = index.get_mut(hash) { + metadata.ref_count += 1; + Ok(()) + } else { + Err(format!("Blob not found: {}", hash)) + } + } + + /// Add a reference to a blob (used when creating file references) + pub async fn add_reference(&self, hash: &str) -> Result<(), String> { + self.increment_ref_count(hash).await?; + + // Update stats + if let Some(metadata) = self.get_blob_metadata(hash).await { + let mut stats = self.stats.write().await; + stats.total_bytes_referenced += metadata.size; + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + } + + Ok(()) + } + + /// Remove a reference to a blob, delete blob if ref_count reaches 0 + pub async fn remove_reference(&self, hash: &str) -> Result { + let should_delete = { + let mut index = self.index.write().await; + + if let Some(metadata) = index.get_mut(hash) { + metadata.ref_count = metadata.ref_count.saturating_sub(1); + + // Update stats + { + let mut stats = self.stats.write().await; + stats.total_bytes_referenced = + stats.total_bytes_referenced.saturating_sub(metadata.size); + stats.bytes_saved = stats.bytes_saved.saturating_sub(metadata.size); + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } + } + + metadata.ref_count == 0 + } else { + return Ok(false); + } + }; + + if should_delete { + // Remove from index + let removed_metadata = { + let mut index = self.index.write().await; + index.remove(hash) + }; + + if let Some(metadata) = removed_metadata { + // Delete blob file + let blob_path = self.blob_path(hash); + if let Err(e) = fs::remove_file(&blob_path).await { + tracing::warn!("Failed to delete blob {}: {}", hash, e); + } + + // Update stats + { + let mut stats = self.stats.write().await; + stats.total_blobs = stats.total_blobs.saturating_sub(1); + stats.total_bytes_stored = + stats.total_bytes_stored.saturating_sub(metadata.size); + if stats.total_bytes_stored > 0 { + stats.dedup_ratio = + stats.total_bytes_referenced as f64 / stats.total_bytes_stored as f64; + } else { + stats.dedup_ratio = 1.0; + } + } + + tracing::info!("🗑️ BLOB DELETED: {} (no more references)", &hash[..12]); + } + + Ok(true) + } else { + tracing::debug!("📎 Reference removed from blob {}", &hash[..12]); + Ok(false) + } + } + + /// Read blob content + pub async fn read_blob(&self, hash: &str) -> Result, String> { + let blob_path = self.blob_path(hash); + + if !blob_path.exists() { + return Err(format!("Blob not found: {}", hash)); + } + + fs::read(&blob_path) + .await + .map_err(|e| format!("Failed to read blob: {}", e)) + } + + /// Read blob as Bytes + pub async fn read_blob_bytes(&self, hash: &str) -> Result { + self.read_blob(hash).await.map(Bytes::from) + } + + /// Get statistics + pub async fn get_stats(&self) -> DedupStats { + self.stats.read().await.clone() + } + + /// Flush index to disk + pub async fn flush(&self) -> std::io::Result<()> { + self.save_index().await + } + + /// Verify integrity of all blobs + pub async fn verify_integrity(&self) -> Result, String> { + let mut corrupted = Vec::new(); + let index = self.index.read().await; + + for (hash, metadata) in index.iter() { + let blob_path = self.blob_path(hash); + + // Check file exists + if !blob_path.exists() { + corrupted.push(format!("{}: file missing", hash)); + continue; + } + + // Verify hash + match Self::hash_file(&blob_path).await { + Ok(actual_hash) => { + if actual_hash != *hash { + corrupted + .push(format!("{}: hash mismatch (actual: {})", hash, actual_hash)); + } + } + Err(e) => { + corrupted.push(format!("{}: read error ({})", hash, e)); + } + } + + // Check size + if let Ok(file_meta) = fs::metadata(&blob_path).await + && file_meta.len() != metadata.size + { + corrupted.push(format!( + "{}: size mismatch (expected: {}, actual: {})", + hash, + metadata.size, + file_meta.len() + )); + } + } + + if corrupted.is_empty() { + tracing::info!("✅ Integrity check passed for {} blobs", index.len()); + } else { + tracing::warn!("⚠️ Integrity check found {} issues", corrupted.len()); + } + + Ok(corrupted) + } + + /// Garbage collect orphaned blobs (blobs with ref_count=0) + pub async fn garbage_collect(&self) -> Result<(u64, u64), String> { + let orphans: Vec<(String, u64)> = { + let index = self.index.read().await; + index + .iter() + .filter(|(_, m)| m.ref_count == 0) + .map(|(h, m)| (h.clone(), m.size)) + .collect() + }; + + let mut deleted_count = 0u64; + let mut deleted_bytes = 0u64; + + for (hash, size) in orphans { + if self.remove_reference(&hash).await.is_ok() { + deleted_count += 1; + deleted_bytes += size; + } + } + + if deleted_count > 0 { + let _ = self.save_index().await; + tracing::info!( + "🧹 Garbage collected {} blobs ({} bytes)", + deleted_count, + deleted_bytes + ); + } + + Ok((deleted_count, deleted_bytes)) + } +} + +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert infra DedupResult to port DedupResultDto. +impl From for DedupResultDto { + fn from(result: DedupResult) -> Self { + match result { + DedupResult::NewBlob { + hash, + size, + blob_path, + } => DedupResultDto::NewBlob { + hash, + size, + blob_path, + }, + DedupResult::ExistingBlob { + hash, + size, + blob_path, + saved_bytes, + } => DedupResultDto::ExistingBlob { + hash, + size, + blob_path, + saved_bytes, + }, + } + } +} + +/// Convert infra BlobMetadata to port BlobMetadataDto. +impl From for BlobMetadataDto { + fn from(m: BlobMetadata) -> Self { + BlobMetadataDto { + hash: m.hash, + size: m.size, + ref_count: m.ref_count, + content_type: m.content_type, + } + } +} + +/// Convert infra DedupStats to port DedupStatsDto. +impl From for DedupStatsDto { + fn from(s: DedupStats) -> Self { + DedupStatsDto { + total_blobs: s.total_blobs, + total_bytes_stored: s.total_bytes_stored, + total_bytes_referenced: s.total_bytes_referenced, + bytes_saved: s.bytes_saved, + dedup_hits: s.dedup_hits, + dedup_ratio: s.dedup_ratio, + } + } +} + +#[async_trait] +impl DedupPort for DedupService { + async fn store_bytes( + &self, + content: &[u8], + content_type: Option, + ) -> Result { + self.store_bytes(content, content_type) + .await + .map(Into::into) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) + } + + async fn store_from_file( + &self, + source_path: &Path, + content_type: Option, + ) -> Result { + self.store_from_file(source_path, content_type) + .await + .map(Into::into) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) + } + + async fn blob_exists(&self, hash: &str) -> bool { + self.blob_exists(hash).await + } + + async fn get_blob_metadata(&self, hash: &str) -> Option { + self.get_blob_metadata(hash).await.map(Into::into) + } + + async fn read_blob(&self, hash: &str) -> Result, DomainError> { + self.read_blob(hash) + .await + .map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e)) + } + + async fn read_blob_bytes(&self, hash: &str) -> Result { + self.read_blob_bytes(hash) + .await + .map_err(|e| DomainError::new(ErrorKind::NotFound, "Blob", e)) + } + + async fn add_reference(&self, hash: &str) -> Result<(), DomainError> { + self.add_reference(hash) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e)) + } + + async fn remove_reference(&self, hash: &str) -> Result { + self.remove_reference(hash) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Blob", e)) + } + + fn hash_bytes(&self, content: &[u8]) -> String { + DedupService::hash_bytes(content) + } + + async fn hash_file(&self, path: &Path) -> Result { + DedupService::hash_file(path) + .await + .map_err(DomainError::from) + } + + async fn get_stats(&self) -> DedupStatsDto { + self.get_stats().await.into() + } + + async fn flush(&self) -> Result<(), DomainError> { + self.flush().await.map_err(DomainError::from) + } + + async fn verify_integrity(&self) -> Result, DomainError> { + self.verify_integrity() + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Dedup", e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_dedup_identical_content() { + let temp_dir = TempDir::new().unwrap(); + let service = DedupService::new(temp_dir.path()); + service.initialize().await.unwrap(); + + // Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in + let content = &b"Hello, World! This is test content for dedup. ".repeat(100); + + // First store + let result1 = service.store_bytes(content, None).await.unwrap(); + assert!(!result1.was_deduplicated()); + + // Second store (same content) + let result2 = service.store_bytes(content, None).await.unwrap(); + assert!(result2.was_deduplicated()); + assert_eq!(result1.hash(), result2.hash()); + + // Check stats + let stats = service.get_stats().await; + assert_eq!(stats.total_blobs, 1); + assert_eq!(stats.dedup_hits, 1); + } + + #[tokio::test] + async fn test_reference_counting() { + let temp_dir = TempDir::new().unwrap(); + let service = DedupService::new(temp_dir.path()); + service.initialize().await.unwrap(); + + // Content must be >= MIN_DEDUP_SIZE (4096 bytes) for dedup to kick in + let content = &b"Test content for reference counting. ".repeat(120); + + // Store twice + let result1 = service.store_bytes(content, None).await.unwrap(); + let _result2 = service.store_bytes(content, None).await.unwrap(); + + let hash = result1.hash().to_string(); + + // Check ref count + let metadata = service.get_blob_metadata(&hash).await.unwrap(); + assert_eq!(metadata.ref_count, 2); + + // Remove one reference + let deleted = service.remove_reference(&hash).await.unwrap(); + assert!(!deleted); + + // Remove second reference (should delete) + let deleted = service.remove_reference(&hash).await.unwrap(); + assert!(deleted); + + // Blob should be gone + assert!(!service.blob_exists(&hash).await); + } +} diff --git a/src/infrastructure/services/file_content_cache.rs b/src/infrastructure/services/file_content_cache.rs index f3fa5ed2..df516471 100644 --- a/src/infrastructure/services/file_content_cache.rs +++ b/src/infrastructure/services/file_content_cache.rs @@ -1,312 +1,352 @@ -use bytes::Bytes; -use lru::LruCache; -use std::num::NonZeroUsize; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; - -/// Configuration for the file content cache -#[derive(Debug, Clone)] -pub struct FileContentCacheConfig { - /// Maximum size of individual files to cache (bytes) - pub max_file_size: usize, - /// Maximum total cache size (bytes) - pub max_total_size: usize, - /// Maximum number of entries - pub max_entries: usize, -} - -impl Default for FileContentCacheConfig { - fn default() -> Self { - Self { - max_file_size: 10 * 1024 * 1024, // 10MB max per file - max_total_size: 512 * 1024 * 1024, // 512MB total cache - max_entries: 10000, // Max 10k files - } - } -} - -impl FileContentCacheConfig { - /// Create a new configuration with custom values - pub fn new(max_file_mb: usize, max_total_mb: usize, max_entries: usize) -> Self { - Self { - max_file_size: max_file_mb * 1024 * 1024, - max_total_size: max_total_mb * 1024 * 1024, - max_entries, - } - } -} - -/// Cache entry with metadata -#[derive(Clone)] -struct CacheEntry { - content: Bytes, - etag: String, - content_type: String, -} - -/// LRU-based file content cache for small/frequently accessed files -/// -/// This cache stores the actual content of files in memory for ultra-fast access. -/// It uses an LRU eviction policy and respects memory limits. -pub struct FileContentCache { - cache: RwLock>, - config: FileContentCacheConfig, - current_size: AtomicUsize, - hits: AtomicUsize, - misses: AtomicUsize, -} - -impl FileContentCache { - /// Create a new file content cache with the given configuration - pub fn new(config: FileContentCacheConfig) -> Self { - let max_entries = NonZeroUsize::new(config.max_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()); - - info!( - "Initializing FileContentCache: max_file={}MB, max_total={}MB, max_entries={}", - config.max_file_size / (1024 * 1024), - config.max_total_size / (1024 * 1024), - config.max_entries - ); - - Self { - cache: RwLock::new(LruCache::new(max_entries)), - config, - current_size: AtomicUsize::new(0), - hits: AtomicUsize::new(0), - misses: AtomicUsize::new(0), - } - } - - /// Create a cache with default configuration - pub fn default() -> Self { - Self::new(FileContentCacheConfig::default()) - } - - /// Check if a file should be cached based on its size - pub fn should_cache(&self, size: usize) -> bool { - size <= self.config.max_file_size - } - - /// Get file content from cache - /// - /// Returns (content, etag, content_type) if found - pub async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { - let mut cache = self.cache.write().await; - - if let Some(entry) = cache.get(file_id) { - self.hits.fetch_add(1, Ordering::Relaxed); - debug!("Cache HIT for file: {}", file_id); - return Some((entry.content.clone(), entry.etag.clone(), entry.content_type.clone())); - } - - self.misses.fetch_add(1, Ordering::Relaxed); - debug!("Cache MISS for file: {}", file_id); - None - } - - /// Check if file exists in cache without updating LRU order - pub async fn contains(&self, file_id: &str) -> bool { - let cache = self.cache.read().await; - cache.contains(file_id) - } - - /// Put file content into cache - /// - /// Will evict older entries if necessary to make room. - /// Will not cache if file is too large. - pub async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { - let size = content.len(); - - // Don't cache if too large - if size > self.config.max_file_size { - debug!("File {} too large to cache: {} bytes", file_id, size); - return; - } - - // Evict entries until we have room - while self.current_size.load(Ordering::Relaxed) + size > self.config.max_total_size { - let mut cache = self.cache.write().await; - if let Some((evicted_id, evicted_entry)) = cache.pop_lru() { - let evicted_size = evicted_entry.content.len(); - self.current_size.fetch_sub(evicted_size, Ordering::Relaxed); - debug!("Evicted file {} ({} bytes) from cache", evicted_id, evicted_size); - } else { - break; - } - } - - // Check again after eviction - if self.current_size.load(Ordering::Relaxed) + size > self.config.max_total_size { - warn!("Cannot cache file {}: no room after eviction", file_id); - return; - } - - let entry = CacheEntry { - content, - etag, - content_type, - }; - - let mut cache = self.cache.write().await; - - // If replacing an existing entry, subtract its size first - if let Some(old_entry) = cache.peek(&file_id) { - self.current_size.fetch_sub(old_entry.content.len(), Ordering::Relaxed); - } - - cache.put(file_id.clone(), entry); - self.current_size.fetch_add(size, Ordering::Relaxed); - - debug!("Cached file {} ({} bytes)", file_id, size); - } - - /// Remove a file from cache (e.g., when file is deleted or modified) - pub async fn invalidate(&self, file_id: &str) { - let mut cache = self.cache.write().await; - if let Some(entry) = cache.pop(file_id) { - self.current_size.fetch_sub(entry.content.len(), Ordering::Relaxed); - debug!("Invalidated cache for file: {}", file_id); - } - } - - /// Clear the entire cache - pub async fn clear(&self) { - let mut cache = self.cache.write().await; - cache.clear(); - self.current_size.store(0, Ordering::Relaxed); - info!("Cache cleared"); - } - - /// Get cache statistics - pub fn stats(&self) -> CacheStats { - let hits = self.hits.load(Ordering::Relaxed); - let misses = self.misses.load(Ordering::Relaxed); - let total = hits + misses; - let hit_rate = if total > 0 { - (hits as f64 / total as f64) * 100.0 - } else { - 0.0 - }; - - CacheStats { - current_size_bytes: self.current_size.load(Ordering::Relaxed), - max_size_bytes: self.config.max_total_size, - hits, - misses, - hit_rate_percent: hit_rate, - } - } -} - -/// Cache statistics -#[derive(Debug, Clone)] -pub struct CacheStats { - pub current_size_bytes: usize, - pub max_size_bytes: usize, - pub hits: usize, - pub misses: usize, - pub hit_rate_percent: f64, -} - -/// Thread-safe wrapper for sharing across handlers -pub type SharedFileContentCache = Arc; - -// ─── ContentCachePort implementation ───────────────────────── - -use async_trait::async_trait; -use crate::application::ports::cache_ports::ContentCachePort; - -#[async_trait] -impl ContentCachePort for FileContentCache { - fn should_cache(&self, size: usize) -> bool { - FileContentCache::should_cache(self, size) - } - - async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { - FileContentCache::get(self, file_id).await - } - - async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { - FileContentCache::put(self, file_id, content, etag, content_type).await - } - - async fn invalidate(&self, file_id: &str) { - FileContentCache::invalidate(self, file_id).await - } - - async fn clear(&self) { - FileContentCache::clear(self).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_cache_put_get() { - let cache = FileContentCache::new(FileContentCacheConfig { - max_file_size: 1024, - max_total_size: 4096, - max_entries: 100, - }); - - let content = Bytes::from("Hello, World!"); - cache.put( - "file1".to_string(), - content.clone(), - "etag1".to_string(), - "text/plain".to_string() - ).await; - - let result = cache.get("file1").await; - assert!(result.is_some()); - let (cached_content, etag, content_type) = result.unwrap(); - assert_eq!(cached_content, content); - assert_eq!(etag, "etag1"); - assert_eq!(content_type, "text/plain"); - } - - #[tokio::test] - async fn test_cache_eviction() { - let cache = FileContentCache::new(FileContentCacheConfig { - max_file_size: 100, - max_total_size: 200, - max_entries: 100, - }); - - // Add first file (100 bytes) - let content1 = Bytes::from(vec![0u8; 100]); - cache.put("file1".to_string(), content1, "e1".to_string(), "app/bin".to_string()).await; - - // Add second file (100 bytes) - let content2 = Bytes::from(vec![1u8; 100]); - cache.put("file2".to_string(), content2, "e2".to_string(), "app/bin".to_string()).await; - - // Add third file - should evict file1 - let content3 = Bytes::from(vec![2u8; 100]); - cache.put("file3".to_string(), content3, "e3".to_string(), "app/bin".to_string()).await; - - // file1 should be evicted - assert!(cache.get("file1").await.is_none()); - // file2 and file3 should exist - assert!(cache.get("file2").await.is_some()); - assert!(cache.get("file3").await.is_some()); - } - - #[tokio::test] - async fn test_cache_invalidate() { - let cache = FileContentCache::new(FileContentCacheConfig::default()); - - let content = Bytes::from("test"); - cache.put("file1".to_string(), content, "e".to_string(), "t".to_string()).await; - - assert!(cache.get("file1").await.is_some()); - - cache.invalidate("file1").await; - - assert!(cache.get("file1").await.is_none()); - } -} +use bytes::Bytes; +use lru::LruCache; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +/// Configuration for the file content cache +#[derive(Debug, Clone)] +pub struct FileContentCacheConfig { + /// Maximum size of individual files to cache (bytes) + pub max_file_size: usize, + /// Maximum total cache size (bytes) + pub max_total_size: usize, + /// Maximum number of entries + pub max_entries: usize, +} + +impl Default for FileContentCacheConfig { + fn default() -> Self { + Self { + max_file_size: 10 * 1024 * 1024, // 10MB max per file + max_total_size: 512 * 1024 * 1024, // 512MB total cache + max_entries: 10000, // Max 10k files + } + } +} + +impl FileContentCacheConfig { + /// Create a new configuration with custom values + pub fn new(max_file_mb: usize, max_total_mb: usize, max_entries: usize) -> Self { + Self { + max_file_size: max_file_mb * 1024 * 1024, + max_total_size: max_total_mb * 1024 * 1024, + max_entries, + } + } +} + +/// Cache entry with metadata +#[derive(Clone)] +struct CacheEntry { + content: Bytes, + etag: String, + content_type: String, +} + +/// LRU-based file content cache for small/frequently accessed files +/// +/// This cache stores the actual content of files in memory for ultra-fast access. +/// It uses an LRU eviction policy and respects memory limits. +pub struct FileContentCache { + cache: RwLock>, + config: FileContentCacheConfig, + current_size: AtomicUsize, + hits: AtomicUsize, + misses: AtomicUsize, +} + +impl FileContentCache { + /// Create a new file content cache with the given configuration + pub fn new(config: FileContentCacheConfig) -> Self { + let max_entries = + NonZeroUsize::new(config.max_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()); + + info!( + "Initializing FileContentCache: max_file={}MB, max_total={}MB, max_entries={}", + config.max_file_size / (1024 * 1024), + config.max_total_size / (1024 * 1024), + config.max_entries + ); + + Self { + cache: RwLock::new(LruCache::new(max_entries)), + config, + current_size: AtomicUsize::new(0), + hits: AtomicUsize::new(0), + misses: AtomicUsize::new(0), + } + } + + /// Create a cache with default configuration + pub fn default() -> Self { + Self::new(FileContentCacheConfig::default()) + } + + /// Check if a file should be cached based on its size + pub fn should_cache(&self, size: usize) -> bool { + size <= self.config.max_file_size + } + + /// Get file content from cache + /// + /// Returns (content, etag, content_type) if found + pub async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { + let mut cache = self.cache.write().await; + + if let Some(entry) = cache.get(file_id) { + self.hits.fetch_add(1, Ordering::Relaxed); + debug!("Cache HIT for file: {}", file_id); + return Some(( + entry.content.clone(), + entry.etag.clone(), + entry.content_type.clone(), + )); + } + + self.misses.fetch_add(1, Ordering::Relaxed); + debug!("Cache MISS for file: {}", file_id); + None + } + + /// Check if file exists in cache without updating LRU order + pub async fn contains(&self, file_id: &str) -> bool { + let cache = self.cache.read().await; + cache.contains(file_id) + } + + /// Put file content into cache + /// + /// Will evict older entries if necessary to make room. + /// Will not cache if file is too large. + pub async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { + let size = content.len(); + + // Don't cache if too large + if size > self.config.max_file_size { + debug!("File {} too large to cache: {} bytes", file_id, size); + return; + } + + // Evict entries until we have room + while self.current_size.load(Ordering::Relaxed) + size > self.config.max_total_size { + let mut cache = self.cache.write().await; + if let Some((evicted_id, evicted_entry)) = cache.pop_lru() { + let evicted_size = evicted_entry.content.len(); + self.current_size.fetch_sub(evicted_size, Ordering::Relaxed); + debug!( + "Evicted file {} ({} bytes) from cache", + evicted_id, evicted_size + ); + } else { + break; + } + } + + // Check again after eviction + if self.current_size.load(Ordering::Relaxed) + size > self.config.max_total_size { + warn!("Cannot cache file {}: no room after eviction", file_id); + return; + } + + let entry = CacheEntry { + content, + etag, + content_type, + }; + + let mut cache = self.cache.write().await; + + // If replacing an existing entry, subtract its size first + if let Some(old_entry) = cache.peek(&file_id) { + self.current_size + .fetch_sub(old_entry.content.len(), Ordering::Relaxed); + } + + cache.put(file_id.clone(), entry); + self.current_size.fetch_add(size, Ordering::Relaxed); + + debug!("Cached file {} ({} bytes)", file_id, size); + } + + /// Remove a file from cache (e.g., when file is deleted or modified) + pub async fn invalidate(&self, file_id: &str) { + let mut cache = self.cache.write().await; + if let Some(entry) = cache.pop(file_id) { + self.current_size + .fetch_sub(entry.content.len(), Ordering::Relaxed); + debug!("Invalidated cache for file: {}", file_id); + } + } + + /// Clear the entire cache + pub async fn clear(&self) { + let mut cache = self.cache.write().await; + cache.clear(); + self.current_size.store(0, Ordering::Relaxed); + info!("Cache cleared"); + } + + /// Get cache statistics + pub fn stats(&self) -> CacheStats { + let hits = self.hits.load(Ordering::Relaxed); + let misses = self.misses.load(Ordering::Relaxed); + let total = hits + misses; + let hit_rate = if total > 0 { + (hits as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + + CacheStats { + current_size_bytes: self.current_size.load(Ordering::Relaxed), + max_size_bytes: self.config.max_total_size, + hits, + misses, + hit_rate_percent: hit_rate, + } + } +} + +/// Cache statistics +#[derive(Debug, Clone)] +pub struct CacheStats { + pub current_size_bytes: usize, + pub max_size_bytes: usize, + pub hits: usize, + pub misses: usize, + pub hit_rate_percent: f64, +} + +/// Thread-safe wrapper for sharing across handlers +pub type SharedFileContentCache = Arc; + +// ─── ContentCachePort implementation ───────────────────────── + +use crate::application::ports::cache_ports::ContentCachePort; +use async_trait::async_trait; + +#[async_trait] +impl ContentCachePort for FileContentCache { + fn should_cache(&self, size: usize) -> bool { + FileContentCache::should_cache(self, size) + } + + async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { + FileContentCache::get(self, file_id).await + } + + async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { + FileContentCache::put(self, file_id, content, etag, content_type).await + } + + async fn invalidate(&self, file_id: &str) { + FileContentCache::invalidate(self, file_id).await + } + + async fn clear(&self) { + FileContentCache::clear(self).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_cache_put_get() { + let cache = FileContentCache::new(FileContentCacheConfig { + max_file_size: 1024, + max_total_size: 4096, + max_entries: 100, + }); + + let content = Bytes::from("Hello, World!"); + cache + .put( + "file1".to_string(), + content.clone(), + "etag1".to_string(), + "text/plain".to_string(), + ) + .await; + + let result = cache.get("file1").await; + assert!(result.is_some()); + let (cached_content, etag, content_type) = result.unwrap(); + assert_eq!(cached_content, content); + assert_eq!(etag, "etag1"); + assert_eq!(content_type, "text/plain"); + } + + #[tokio::test] + async fn test_cache_eviction() { + let cache = FileContentCache::new(FileContentCacheConfig { + max_file_size: 100, + max_total_size: 200, + max_entries: 100, + }); + + // Add first file (100 bytes) + let content1 = Bytes::from(vec![0u8; 100]); + cache + .put( + "file1".to_string(), + content1, + "e1".to_string(), + "app/bin".to_string(), + ) + .await; + + // Add second file (100 bytes) + let content2 = Bytes::from(vec![1u8; 100]); + cache + .put( + "file2".to_string(), + content2, + "e2".to_string(), + "app/bin".to_string(), + ) + .await; + + // Add third file - should evict file1 + let content3 = Bytes::from(vec![2u8; 100]); + cache + .put( + "file3".to_string(), + content3, + "e3".to_string(), + "app/bin".to_string(), + ) + .await; + + // file1 should be evicted + assert!(cache.get("file1").await.is_none()); + // file2 and file3 should exist + assert!(cache.get("file2").await.is_some()); + assert!(cache.get("file3").await.is_some()); + } + + #[tokio::test] + async fn test_cache_invalidate() { + let cache = FileContentCache::new(FileContentCacheConfig::default()); + + let content = Bytes::from("test"); + cache + .put( + "file1".to_string(), + content, + "e".to_string(), + "t".to_string(), + ) + .await; + + assert!(cache.get("file1").await.is_some()); + + cache.invalidate("file1").await; + + assert!(cache.get("file1").await.is_none()); + } +} diff --git a/src/infrastructure/services/file_metadata_cache.rs b/src/infrastructure/services/file_metadata_cache.rs index 0bab8897..becdbe81 100644 --- a/src/infrastructure/services/file_metadata_cache.rs +++ b/src/infrastructure/services/file_metadata_cache.rs @@ -1,3 +1,5 @@ +use futures::future::BoxFuture; +use mime_guess::from_path; use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -5,9 +7,7 @@ use std::time::{Duration, Instant, UNIX_EPOCH}; use tokio::fs; use tokio::sync::RwLock; use tokio::time; -use futures::future::BoxFuture; use tracing::debug; -use mime_guess::from_path; use crate::domain::entities::file::File; @@ -79,7 +79,7 @@ impl FileMetadata { ttl: Duration, ) -> Self { let now = Instant::now(); - + Self { path, exists, @@ -93,18 +93,18 @@ impl FileMetadata { access_count: 1, } } - + /// Updates the last access time pub fn touch(&mut self) { self.last_access = Instant::now(); self.access_count += 1; } - + /// Checks if the entry has expired pub fn is_expired(&self) -> bool { Instant::now() > self.expires_at } - + /// Updates the expiration time with a new TTL pub fn update_expiry(&mut self, ttl: Duration) { self.expires_at = Instant::now() + ttl; @@ -137,12 +137,12 @@ impl FileMetadataCache { lru_queue: RwLock::new(VecDeque::with_capacity(max_entries)), stats: RwLock::new(CacheStats::default()), config, - ttl_multiplier: 5.0, // Popular entries have 5x TTL + ttl_multiplier: 5.0, // Popular entries have 5x TTL popularity_threshold: 10, // After 10 accesses it's considered popular max_entries, } } - + /// Creates a FileMetadata object from a File object pub fn create_metadata_from_file(file: &File, abs_path: PathBuf) -> FileMetadata { let entry_type = CacheEntryType::File; @@ -150,10 +150,10 @@ impl FileMetadataCache { let mime_type = Some(file.mime_type().to_string()); let created_at = Some(file.created_at()); let modified_at = Some(file.modified_at()); - + // Use a standard TTL let ttl = Duration::from_secs(60); // 1 minute - + FileMetadata::new( abs_path, true, @@ -165,147 +165,148 @@ impl FileMetadataCache { ttl, ) } - + /// Creates a default instance pub fn default() -> Self { Self::new(AppConfig::default(), 10_000) } - + /// Creates a cache instance with default configuration pub fn default_with_config(config: AppConfig) -> Self { Self::new(config, 50_000) // Larger cache for production system } - + /// Gets file metadata if cached pub async fn get_metadata(&self, path: &Path) -> Option { let start_time = Instant::now(); let mut cache = self.metadata_cache.write().await; - + if let Some(metadata) = cache.get_mut(path) { // Check if expired if metadata.is_expired() { // Remove from cache if expired cache.remove(path); - + // Update statistics let mut stats = self.stats.write().await; stats.misses += 1; stats.expirations += 1; - + debug!("Cache entry expired for: {}", path.display()); - + return None; } - + // Update access time metadata.touch(); - + // For popular entries, extend TTL if metadata.access_count >= self.popularity_threshold { let new_ttl = match metadata.entry_type { CacheEntryType::File => Duration::from_millis( - (self.config.timeouts.file_operation_ms as f64 * self.ttl_multiplier) as u64 + (self.config.timeouts.file_operation_ms as f64 * self.ttl_multiplier) + as u64, ), CacheEntryType::Directory => Duration::from_millis( - (self.config.timeouts.dir_operation_ms as f64 * self.ttl_multiplier) as u64 + (self.config.timeouts.dir_operation_ms as f64 * self.ttl_multiplier) as u64, ), _ => Duration::from_secs(60), // 1 minute by default }; - + metadata.update_expiry(new_ttl); debug!("Extended TTL for popular entry: {}", path.display()); } - + // Calculate approximate time saved let elapsed = start_time.elapsed().as_millis() as u64; let estimated_io_time: u64 = 10; // We assume 10ms minimum for IO operation let time_saved = estimated_io_time.saturating_sub(elapsed); - + // Update statistics let mut stats = self.stats.write().await; stats.hits += 1; stats.time_saved_ms += time_saved; - + debug!("Cache hit for: {}", path.display()); - + // Also keep the LRU queue updated self.update_lru(path.to_path_buf()).await; - + // Clone to return return Some(metadata.clone()); } - + // Not found in cache let mut stats = self.stats.write().await; stats.misses += 1; - + debug!("Cache miss for: {}", path.display()); None } - + /// Updates the LRU queue async fn update_lru(&self, path: PathBuf) { let mut lru = self.lru_queue.write().await; - + // Remove if already exists if let Some(pos) = lru.iter().position(|p| p == &path) { lru.remove(pos); } - + // Add to the end (most recent) lru.push_back(path); } - + /// Checks if a file exists pub async fn exists(&self, path: &Path) -> Option { if let Some(metadata) = self.get_metadata(path).await { return Some(metadata.exists); } - + None } - + /// Checks if a path is a directory pub async fn is_dir(&self, path: &Path) -> Option { if let Some(metadata) = self.get_metadata(path).await { return Some(metadata.entry_type == CacheEntryType::Directory); } - + None } - + /// Checks if a path is a file pub async fn is_file(&self, path: &Path) -> Option { if let Some(metadata) = self.get_metadata(path).await { return Some(metadata.entry_type == CacheEntryType::File); } - + None } - + /// Gets the size of a file pub async fn get_size(&self, path: &Path) -> Option { if let Some(metadata) = self.get_metadata(path).await { return metadata.size; } - + None } - + /// Gets the MIME type of a file pub async fn get_mime_type(&self, path: &Path) -> Option { if let Some(metadata) = self.get_metadata(path).await { return metadata.mime_type; } - + None } - + /// Refreshes metadata for a path pub async fn refresh_metadata(&self, path: &Path) -> Result { // Perform actual filesystem read let metadata = fs::metadata(path).await?; - + // Determine entry type let entry_type = if metadata.is_dir() { CacheEntryType::Directory @@ -314,37 +315,47 @@ impl FileMetadataCache { } else { CacheEntryType::Unknown }; - + // Get size for files let size = if metadata.is_file() { Some(metadata.len()) } else { None }; - + // Get MIME type for files let mime_type = if metadata.is_file() { Some(from_path(path).first_or_octet_stream().to_string()) } else { None }; - + // Get timestamps - let created_at = metadata.created() - .map(|time| time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) + let created_at = metadata + .created() + .map(|time| { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .ok(); - - let modified_at = metadata.modified() - .map(|time| time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) + + let modified_at = metadata + .modified() + .map(|time| { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) .ok(); - + // Determine appropriate TTL let ttl = if metadata.is_dir() { Duration::from_millis(self.config.timeouts.dir_operation_ms) } else { Duration::from_millis(self.config.timeouts.file_operation_ms) }; - + // Create metadata entry let file_metadata = FileMetadata::new( path.to_path_buf(), @@ -356,50 +367,50 @@ impl FileMetadataCache { modified_at, ttl, ); - + // Update cache self.update_cache(file_metadata.clone()).await; - + Ok(file_metadata) } - + /// Updates the cache with new metadata pub async fn update_cache(&self, metadata: FileMetadata) { // Avoid full cache before inserting self.ensure_capacity().await; - + let path = metadata.path.clone(); - + // Insert into cache { let mut cache = self.metadata_cache.write().await; cache.insert(path.clone(), metadata); - + // Update statistics let mut stats = self.stats.write().await; stats.inserts += 1; } - + // Update the LRU queue self.update_lru(path).await; } - + /// Ensures there is space in the cache async fn ensure_capacity(&self) { let cache_size = { let cache = self.metadata_cache.read().await; cache.len() }; - + if cache_size >= self.max_entries { self.evict_lru_entries(cache_size / 10).await; // Free up 10% } } - + /// Removes least recently used entries async fn evict_lru_entries(&self, count: usize) { let mut paths_to_remove = Vec::with_capacity(count); - + // Get entries to remove from the LRU queue { let mut lru = self.lru_queue.write().await; @@ -411,7 +422,7 @@ impl FileMetadataCache { } } } - + // Remove from the main cache { let mut cache = self.metadata_cache.write().await; @@ -419,22 +430,22 @@ impl FileMetadataCache { cache.remove(&path); } } - + debug!("Evicted {} LRU entries from cache", count); } - + /// Invalidate a specific cache entry pub async fn invalidate(&self, path: &Path) { // Remove from the main cache { let mut cache = self.metadata_cache.write().await; cache.remove(path); - + // Update statistics let mut stats = self.stats.write().await; stats.invalidations += 1; } - + // Remove from the LRU queue let path_buf = path.to_path_buf(); { @@ -443,15 +454,15 @@ impl FileMetadataCache { lru.remove(pos); } } - + debug!("Invalidated cache entry for: {}", path.display()); } - + /// Recursively invalidate entries under a directory pub async fn invalidate_directory(&self, dir_path: &Path) { let dir_str = dir_path.to_string_lossy().to_string(); let mut paths_to_remove = Vec::new(); - + // Find all paths that start with the directory { let cache = self.metadata_cache.read().await; @@ -462,32 +473,32 @@ impl FileMetadataCache { } } } - + // Update statistics { let mut stats = self.stats.write().await; stats.invalidations += paths_to_remove.len(); } - + // Remove each found path for path in paths_to_remove { self.invalidate(&path).await; } - + debug!("Invalidated directory and contents: {}", dir_path.display()); } - + /// Get current cache statistics pub async fn get_stats(&self) -> CacheStats { let stats = self.stats.read().await; stats.clone() } - + /// Clears all expired entries from the cache pub async fn clear_expired(&self) { let now = Instant::now(); let mut paths_to_remove = Vec::new(); - + // Find expired entries { let cache = self.metadata_cache.read().await; @@ -497,112 +508,116 @@ impl FileMetadataCache { } } } - + // Update statistics { let mut stats = self.stats.write().await; stats.expirations += paths_to_remove.len(); } - + // Save the number of entries for logging let num_paths = paths_to_remove.len(); - + // Remove expired entries for path in paths_to_remove { self.invalidate(&path).await; } - + debug!("Cleared {} expired entries from cache", num_paths); } - + /// Starts the periodic cleanup process pub fn start_cleanup_task(cache: Arc) -> BoxFuture<'static, ()> { Box::pin(async move { let cleanup_interval = Duration::from_secs(60); // Every minute - + loop { // Wait for the interval time::sleep(cleanup_interval).await; - + // Clean expired entries cache.clear_expired().await; - + // Log statistics let stats = cache.get_stats().await; let cache_size = { let cache_map = cache.metadata_cache.read().await; cache_map.len() }; - + debug!( "Cache stats: size={}, hits={}, misses={}, hit_ratio={:.2}%, time_saved={}ms", cache_size, stats.hits, stats.misses, - if stats.hits + stats.misses > 0 { + if stats.hits + stats.misses > 0 { (stats.hits as f64 * 100.0) / (stats.hits + stats.misses) as f64 - } else { - 0.0 + } else { + 0.0 }, stats.time_saved_ms ); } }) } - + /// Preloads metadata for entire directories (useful for initialization) - pub async fn preload_directory(&self, dir_path: &Path, recursive: bool, max_depth: usize) -> Result { - self._preload_directory_internal(dir_path, recursive, max_depth, 0).await + pub async fn preload_directory( + &self, + dir_path: &Path, + recursive: bool, + max_depth: usize, + ) -> Result { + self._preload_directory_internal(dir_path, recursive, max_depth, 0) + .await } - + /// Internal preload implementation with depth tracking async fn _preload_directory_internal( - &self, - dir_path: &Path, - recursive: bool, - max_depth: usize, - current_depth: usize + &self, + dir_path: &Path, + recursive: bool, + max_depth: usize, + current_depth: usize, ) -> Result { Box::pin(async move { - if current_depth > max_depth { - return Ok(0); - } - - // Get directory entries - let mut entries = fs::read_dir(dir_path).await?; - let mut count = 0; - - // Process each entry - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - let metadata = fs::metadata(&path).await?; - - // Refresh metadata for this entry - self.refresh_metadata(&path).await?; - count += 1; - - // Recursively process subdirectories if needed - if recursive && metadata.is_dir() { - // Box to break recursion - count += self._preload_directory_internal( - &path, - recursive, - max_depth, - current_depth + 1 - ).await?; + if current_depth > max_depth { + return Ok(0); } - } - - Ok(count) - }).await + + // Get directory entries + let mut entries = fs::read_dir(dir_path).await?; + let mut count = 0; + + // Process each entry + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let metadata = fs::metadata(&path).await?; + + // Refresh metadata for this entry + self.refresh_metadata(&path).await?; + count += 1; + + // Recursively process subdirectories if needed + if recursive && metadata.is_dir() { + // Box to break recursion + count += self + ._preload_directory_internal(&path, recursive, max_depth, current_depth + 1) + .await?; + } + } + + Ok(count) + }) + .await } } // ─── MetadataCachePort implementation ──────────────────────── -use async_trait::async_trait; -use crate::application::ports::cache_ports::{MetadataCachePort, CachedMetadataDto}; +use crate::application::ports::cache_ports::{CachedMetadataDto, MetadataCachePort}; use crate::common::errors::DomainError; +use async_trait::async_trait; #[async_trait] impl MetadataCachePort for FileMetadataCache { @@ -654,46 +669,46 @@ mod tests { use tempfile::tempdir; use tokio::fs::File; use tokio::io::AsyncWriteExt; - + #[tokio::test] async fn test_cache_operations() { // Create temporary directory for tests let temp_dir = tempdir().unwrap(); let file_path = temp_dir.path().join("test_file.txt"); - + // Create a test file let mut file = File::create(&file_path).await.unwrap(); file.write_all(b"test content").await.unwrap(); file.flush().await.unwrap(); drop(file); - + // Create cache let config = AppConfig::default(); let cache = FileMetadataCache::new(config, 1000); - + // Verify initial miss assert!(cache.exists(&file_path).await.is_none()); - + // Refresh and verify hit let metadata = cache.refresh_metadata(&file_path).await.unwrap(); assert_eq!(metadata.entry_type, CacheEntryType::File); assert_eq!(metadata.size, Some(12)); // "test content" = 12 bytes - + // Verify it now exists in cache assert_eq!(cache.exists(&file_path).await, Some(true)); assert_eq!(cache.is_file(&file_path).await, Some(true)); - + // Invalidate and verify it no longer exists in cache cache.invalidate(&file_path).await; assert!(cache.exists(&file_path).await.is_none()); - + // Verify statistics let stats = cache.get_stats().await; assert_eq!(stats.inserts, 1); assert_eq!(stats.invalidations, 1); assert!(stats.hits > 0); } - + #[tokio::test] async fn test_directory_operations() { // Create directory structure for tests @@ -702,33 +717,33 @@ mod tests { let base_path = temp_dir.path().canonicalize().unwrap(); let sub_dir = base_path.join("subdir"); fs::create_dir(&sub_dir).await.unwrap(); - + let file1 = base_path.join("file1.txt"); let file2 = sub_dir.join("file2.txt"); - + File::create(&file1).await.unwrap(); File::create(&file2).await.unwrap(); - + // Create cache let config = AppConfig::default(); let cache = FileMetadataCache::new(config, 1000); - + // Preload directory recursively // preload_directory caches the *contents* of the directory, not the root itself let count = cache.preload_directory(&base_path, true, 2).await.unwrap(); assert_eq!(count, 3); // subdir, file1, file2 - + // Verify existence in cache (only contents, not the root) assert_eq!(cache.is_dir(&sub_dir).await, Some(true)); assert_eq!(cache.is_file(&file1).await, Some(true)); assert_eq!(cache.is_file(&file2).await, Some(true)); - + // Invalidate directory and contents cache.invalidate_directory(&base_path).await; - + // Verify nothing exists in cache assert!(cache.exists(&sub_dir).await.is_none()); assert!(cache.exists(&file1).await.is_none()); assert!(cache.exists(&file2).await.is_none()); } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/file_system_i18n_service.rs b/src/infrastructure/services/file_system_i18n_service.rs index c7933356..74220596 100644 --- a/src/infrastructure/services/file_system_i18n_service.rs +++ b/src/infrastructure/services/file_system_i18n_service.rs @@ -1,17 +1,17 @@ +use async_trait::async_trait; +use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; use std::sync::RwLock; -use async_trait::async_trait; -use serde_json::Value; use tokio::fs; -use crate::domain::services::i18n_service::{I18nService, I18nError, I18nResult, Locale}; +use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService, Locale}; /// File system implementation of the I18nService pub struct FileSystemI18nService { /// Base directory containing translation files translations_dir: PathBuf, - + /// Cached translations (locale code -> JSON data) cache: RwLock>, } @@ -32,17 +32,18 @@ impl FileSystemI18nService { cache: RwLock::new(HashMap::new()), } } - + /// Get translation file path for a locale fn get_locale_file_path(&self, locale: Locale) -> PathBuf { - self.translations_dir.join(format!("{}.json", locale.as_str())) + self.translations_dir + .join(format!("{}.json", locale.as_str())) } - + /// Get a nested key from JSON data fn get_nested_value(&self, data: &Value, key: &str) -> Option { let parts: Vec<&str> = key.split('.').collect(); let mut current = data; - + for part in &parts[0..parts.len() - 1] { if let Some(next) = current.get(part) { current = next; @@ -50,13 +51,14 @@ impl FileSystemI18nService { return None; } } - + if let Some(last_part) = parts.last() && let Some(value) = current.get(last_part) - && value.is_string() { - return value.as_str().map(|s| s.to_string()); - } - + && value.is_string() + { + return value.as_str().map(|s| s.to_string()); + } + None } } @@ -71,73 +73,86 @@ impl I18nService for FileSystemI18nService { if let Some(value) = self.get_nested_value(translations, key) { return Ok(value); } - + // Try to use English as fallback if we couldn't find the key if locale != Locale::English && let Some(english_translations) = cache.get(&Locale::English) - && let Some(value) = self.get_nested_value(english_translations, key) { - return Ok(value); - } - + && let Some(value) = self.get_nested_value(english_translations, key) + { + return Ok(value); + } + return Err(I18nError::KeyNotFound(key.to_string())); } } - + // If not cached, load translations and try again self.load_translations(locale).await?; - + { let cache = self.cache.read().unwrap(); if let Some(translations) = cache.get(&locale) { if let Some(value) = self.get_nested_value(translations, key) { return Ok(value); } - + // Try to use English as fallback if locale != Locale::English && let Some(english_translations) = cache.get(&Locale::English) - && let Some(value) = self.get_nested_value(english_translations, key) { - return Ok(value); - } + && let Some(value) = self.get_nested_value(english_translations, key) + { + return Ok(value); + } } } - + Err(I18nError::KeyNotFound(key.to_string())) } - + async fn load_translations(&self, locale: Locale) -> I18nResult<()> { let file_path = self.get_locale_file_path(locale); - tracing::info!("Loading translations for locale {} from {:?}", locale.as_str(), file_path); - + tracing::info!( + "Loading translations for locale {} from {:?}", + locale.as_str(), + file_path + ); + // Check if file exists if !file_path.exists() { return Err(I18nError::InvalidLocale(locale.as_str().to_string())); } - + // Read and parse file let content = fs::read_to_string(&file_path) .await .map_err(|e| I18nError::LoadError(format!("Failed to read translation file: {}", e)))?; - - let translations: Value = serde_json::from_str(&content) - .map_err(|e| I18nError::LoadError(format!("Failed to parse translation file: {}", e)))?; - + + let translations: Value = serde_json::from_str(&content).map_err(|e| { + I18nError::LoadError(format!("Failed to parse translation file: {}", e)) + })?; + // Update cache { let mut cache = self.cache.write().unwrap(); cache.insert(locale, translations); } - + tracing::info!("Translations loaded for locale {}", locale.as_str()); Ok(()) } - + async fn available_locales(&self) -> Vec { - vec![Locale::English, Locale::Spanish, Locale::French, Locale::German, Locale::Portuguese] + vec![ + Locale::English, + Locale::Spanish, + Locale::French, + Locale::German, + Locale::Portuguese, + ] } - + async fn is_supported(&self, locale: Locale) -> bool { let file_path = self.get_locale_file_path(locale); file_path.exists() } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/file_system_utils.rs b/src/infrastructure/services/file_system_utils.rs index 28d79b8e..6cea8302 100644 --- a/src/infrastructure/services/file_system_utils.rs +++ b/src/infrastructure/services/file_system_utils.rs @@ -1,9 +1,9 @@ -use tokio::fs::{self, OpenOptions, File}; -use tokio::io::AsyncWriteExt; -use std::path::Path; use std::io::Error as IoError; +use std::path::Path; use tempfile::NamedTempFile; -use tracing::{warn, error}; +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::AsyncWriteExt; +use tracing::{error, warn}; /// Utility functions for file system operations with proper synchronization pub struct FileSystemUtils; @@ -13,59 +13,73 @@ impl FileSystemUtils { /// Uses a safe atomic write pattern: write to temp file, fsync, rename pub async fn atomic_write>(path: P, contents: &[u8]) -> Result<(), IoError> { let path = path.as_ref(); - + // Ensure parent directory exists if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; } - + // Create a temporary file in the same directory let dir = path.parent().unwrap_or_else(|| Path::new(".")); let temp_file = match NamedTempFile::new_in(dir) { Ok(file) => file, Err(e) => { - error!("Failed to create temporary file in {}: {}", dir.display(), e); - return Err(IoError::other(format!("Failed to create temporary file: {}", e))); + error!( + "Failed to create temporary file in {}: {}", + dir.display(), + e + ); + return Err(IoError::other(format!( + "Failed to create temporary file: {}", + e + ))); } }; - + let temp_path = temp_file.path().to_path_buf(); - + // Convert to tokio file and write contents let std_file = temp_file.as_file().try_clone()?; let mut file = File::from_std(std_file); file.write_all(contents).await?; - + // Ensure data is synced to disk file.flush().await?; file.sync_all().await?; - + // Rename the temporary file to the target path (atomic operation on most filesystems) fs::rename(&temp_path, path).await?; - + // Sync the directory to ensure the rename is persisted if let Some(parent) = path.parent() { match Self::sync_directory(parent).await { - Ok(_) => {}, + Ok(_) => {} Err(e) => { - warn!("Failed to sync directory {}: {}. File was written but directory entry might not be durable.", - parent.display(), e); + warn!( + "Failed to sync directory {}: {}. File was written but directory entry might not be durable.", + parent.display(), + e + ); } } } - + Ok(()) } - + /// Creates or appends to a file with fsync - pub async fn write_with_sync>(path: P, contents: &[u8], append: bool) -> Result<(), IoError> { + pub async fn write_with_sync>( + path: P, + contents: &[u8], + append: bool, + ) -> Result<(), IoError> { let path = path.as_ref(); - + // Ensure parent directory exists if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; } - + // Open file with appropriate options let mut file = OpenOptions::new() .write(true) @@ -74,140 +88,162 @@ impl FileSystemUtils { .append(append) .open(path) .await?; - + // Write contents file.write_all(contents).await?; - + // Ensure data is synced to disk file.flush().await?; file.sync_all().await?; - + Ok(()) } - + /// Creates directories with fsync pub async fn create_dir_with_sync>(path: P) -> Result<(), IoError> { let path = path.as_ref(); - + // Create directory fs::create_dir_all(path).await?; - + // Sync the directory Self::sync_directory(path).await?; - + // Sync parent directory to ensure directory creation is persisted if let Some(parent) = path.parent() { match Self::sync_directory(parent).await { - Ok(_) => {}, + Ok(_) => {} Err(e) => { - warn!("Failed to sync parent directory {}: {}. Directory was created but entry might not be durable.", - parent.display(), e); + warn!( + "Failed to sync parent directory {}: {}. Directory was created but entry might not be durable.", + parent.display(), + e + ); } } } - + Ok(()) } - + /// Renames a file or directory with proper syncing - pub async fn rename_with_sync, Q: AsRef>(from: P, to: Q) -> Result<(), IoError> { + pub async fn rename_with_sync, Q: AsRef>( + from: P, + to: Q, + ) -> Result<(), IoError> { let from = from.as_ref(); let to = to.as_ref(); - + // Ensure parent directory of destination exists if let Some(parent) = to.parent() { fs::create_dir_all(parent).await?; } - + // Perform rename fs::rename(from, to).await?; - + // Sync parent directories to ensure rename is persisted if let Some(from_parent) = from.parent() { match Self::sync_directory(from_parent).await { - Ok(_) => {}, + Ok(_) => {} Err(e) => { - warn!("Failed to sync source directory {}: {}. Rename completed but might not be durable.", - from_parent.display(), e); + warn!( + "Failed to sync source directory {}: {}. Rename completed but might not be durable.", + from_parent.display(), + e + ); } } } - + if let Some(to_parent) = to.parent() { match Self::sync_directory(to_parent).await { - Ok(_) => {}, + Ok(_) => {} Err(e) => { - warn!("Failed to sync destination directory {}: {}. Rename completed but might not be durable.", - to_parent.display(), e); + warn!( + "Failed to sync destination directory {}: {}. Rename completed but might not be durable.", + to_parent.display(), + e + ); } } } - + Ok(()) } - + /// Removes a file with directory syncing pub async fn remove_file_with_sync>(path: P) -> Result<(), IoError> { let path = path.as_ref(); - + // Remove file fs::remove_file(path).await?; - + // Sync parent directory to ensure removal is persisted if let Some(parent) = path.parent() { match Self::sync_directory(parent).await { - Ok(_) => {}, + Ok(_) => {} Err(e) => { - warn!("Failed to sync directory after file removal {}: {}. File was removed but entry might not be durable.", - parent.display(), e); + warn!( + "Failed to sync directory after file removal {}: {}. File was removed but entry might not be durable.", + parent.display(), + e + ); } } } - + Ok(()) } - + /// Removes a directory with parent directory syncing - pub async fn remove_dir_with_sync>(path: P, recursive: bool) -> Result<(), IoError> { + pub async fn remove_dir_with_sync>( + path: P, + recursive: bool, + ) -> Result<(), IoError> { let path = path.as_ref(); - + // Remove directory if recursive { fs::remove_dir_all(path).await?; } else { fs::remove_dir(path).await?; } - + // Sync parent directory to ensure removal is persisted if let Some(parent) = path.parent() { match Self::sync_directory(parent).await { - Ok(_) => {}, + Ok(_) => {} Err(e) => { - warn!("Failed to sync directory after directory removal {}: {}. Directory was removed but entry might not be durable.", - parent.display(), e); + warn!( + "Failed to sync directory after directory removal {}: {}. Directory was removed but entry might not be durable.", + parent.display(), + e + ); } } } - + Ok(()) } - + /// Syncs a directory to ensure its contents are durable async fn sync_directory>(path: P) -> Result<(), IoError> { let path = path.as_ref(); - + // Open directory with read permissions - let dir_file = match OpenOptions::new() - .read(true) - .open(path) - .await { - Ok(file) => file, - Err(e) => { - warn!("Failed to open directory for syncing {}: {}", path.display(), e); - return Err(e); - } - }; - + let dir_file = match OpenOptions::new().read(true).open(path).await { + Ok(file) => file, + Err(e) => { + warn!( + "Failed to open directory for syncing {}: {}", + path.display(), + e + ); + return Err(e); + } + }; + // Sync the directory dir_file.sync_all().await } @@ -219,62 +255,72 @@ mod tests { use tempfile::tempdir; use tokio::fs; use tokio::io::AsyncReadExt; - + #[tokio::test] async fn test_atomic_write() { let temp_dir = tempdir().unwrap(); let file_path = temp_dir.path().join("test.txt"); - + // Write data atomically - FileSystemUtils::atomic_write(&file_path, b"Hello, world!").await.unwrap(); - + FileSystemUtils::atomic_write(&file_path, b"Hello, world!") + .await + .unwrap(); + // Read back the data let mut file = fs::File::open(&file_path).await.unwrap(); let mut contents = String::new(); file.read_to_string(&mut contents).await.unwrap(); - + assert_eq!(contents, "Hello, world!"); } - + #[tokio::test] async fn test_write_with_sync() { let temp_dir = tempdir().unwrap(); let file_path = temp_dir.path().join("test.txt"); - + // Write data with sync - FileSystemUtils::write_with_sync(&file_path, b"First line\n", false).await.unwrap(); - + FileSystemUtils::write_with_sync(&file_path, b"First line\n", false) + .await + .unwrap(); + // Append data - FileSystemUtils::write_with_sync(&file_path, b"Second line", true).await.unwrap(); - + FileSystemUtils::write_with_sync(&file_path, b"Second line", true) + .await + .unwrap(); + // Read back the data let mut file = fs::File::open(&file_path).await.unwrap(); let mut contents = String::new(); file.read_to_string(&mut contents).await.unwrap(); - + assert_eq!(contents, "First line\nSecond line"); } - + #[tokio::test] async fn test_rename_with_sync() { let temp_dir = tempdir().unwrap(); let source_path = temp_dir.path().join("source.txt"); let dest_path = temp_dir.path().join("dest.txt"); - + // Create source file - FileSystemUtils::write_with_sync(&source_path, b"Test content", false).await.unwrap(); - + FileSystemUtils::write_with_sync(&source_path, b"Test content", false) + .await + .unwrap(); + // Rename file - FileSystemUtils::rename_with_sync(&source_path, &dest_path).await.unwrap(); - + FileSystemUtils::rename_with_sync(&source_path, &dest_path) + .await + .unwrap(); + // Verify source doesn't exist assert!(!source_path.exists()); - + // Verify destination exists let mut file = fs::File::open(&dest_path).await.unwrap(); let mut contents = String::new(); file.read_to_string(&mut contents).await.unwrap(); - + assert_eq!(contents, "Test content"); } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/id_mapping_optimizer.rs b/src/infrastructure/services/id_mapping_optimizer.rs index e896c74e..cdb74907 100644 --- a/src/infrastructure/services/id_mapping_optimizer.rs +++ b/src/infrastructure/services/id_mapping_optimizer.rs @@ -1,14 +1,14 @@ +use async_trait::async_trait; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock, Semaphore}; use tracing::{debug, error, info, warn}; -use async_trait::async_trait; -use crate::domain::services::path_service::StoragePath; -use crate::infrastructure::services::id_mapping_service::{IdMappingService, IdMappingError}; -use crate::common::errors::DomainError; use crate::application::ports::outbound::IdMappingPort; +use crate::common::errors::DomainError; +use crate::domain::services::path_service::StoragePath; +use crate::infrastructure::services::id_mapping_service::{IdMappingError, IdMappingService}; /// Maximum number of entries in the cache const MAX_CACHE_SIZE: usize = 10_000; @@ -20,19 +20,19 @@ const CACHE_TTL_SECONDS: u64 = 60 * 5; // 5 minutes pub struct IdMappingOptimizer { /// Base ID mapping service base_service: Arc, - + /// Path to ID cache (path -> id) path_to_id_cache: RwLock>, - + /// ID to path cache (id -> path) id_to_path_cache: RwLock>, - + /// Hit counter stats: RwLock, - + /// Semaphore to limit batch operations batch_limiter: Semaphore, - + /// Pending batch queue pending_batch: Mutex, } @@ -44,17 +44,17 @@ pub struct OptimizerStats { pub path_by_id_queries: usize, /// Number of cache hits for get_path_by_id pub path_by_id_hits: usize, - + /// Total number of get_or_create_id queries pub get_id_queries: usize, /// Number of cache hits for get_or_create_id pub get_id_hits: usize, - + /// Number of batch operations performed pub batch_operations: usize, /// Total number of IDs processed in batch pub batch_items_processed: usize, - + /// Last cache cleanup timestamp pub last_cleanup: Option, } @@ -68,7 +68,6 @@ struct BatchQueue { id_to_path_requests: HashSet, } - /// Result of a batch operation struct BatchResult { /// Path to ID mapping @@ -89,85 +88,93 @@ impl IdMappingOptimizer { pending_batch: Mutex::new(BatchQueue::default()), } } - + /// Gets optimizer statistics pub async fn get_stats(&self) -> OptimizerStats { self.stats.read().await.clone() } - + /// Cleans expired cache entries pub async fn cleanup_cache(&self) { let now = Instant::now(); let ttl = Duration::from_secs(CACHE_TTL_SECONDS); - + // Clean path_to_id cache { let mut cache = self.path_to_id_cache.write().await; let initial_size = cache.len(); - + // Retain only non-expired entries - cache.retain(|_, (_, timestamp)| { - now.duration_since(*timestamp) < ttl - }); - + cache.retain(|_, (_, timestamp)| now.duration_since(*timestamp) < ttl); + let removed = initial_size - cache.len(); if removed > 0 { debug!("Cleaned {} expired entries from path_to_id cache", removed); } } - + // Clean id_to_path cache { let mut cache = self.id_to_path_cache.write().await; let initial_size = cache.len(); - + // Retain only non-expired entries - cache.retain(|_, (_, timestamp)| { - now.duration_since(*timestamp) < ttl - }); - + cache.retain(|_, (_, timestamp)| now.duration_since(*timestamp) < ttl); + let removed = initial_size - cache.len(); if removed > 0 { debug!("Cleaned {} expired entries from id_to_path cache", removed); } } - + // Update statistics { let mut stats = self.stats.write().await; stats.last_cleanup = Some(now); } } - + /// Starts periodic cleanup task pub fn start_cleanup_task(optimizer: Arc) { tokio::spawn(async move { let cleanup_interval = Duration::from_secs(CACHE_TTL_SECONDS / 2); - + loop { tokio::time::sleep(cleanup_interval).await; optimizer.cleanup_cache().await; - + // Log statistics periodically let stats = optimizer.get_stats().await; - info!("ID Mapping Optimizer stats - Path queries: {}, hits: {} ({}%), ID queries: {}, hits: {} ({}%), Batch ops: {}, items: {}", + info!( + "ID Mapping Optimizer stats - Path queries: {}, hits: {} ({}%), ID queries: {}, hits: {} ({}%), Batch ops: {}, items: {}", stats.path_by_id_queries, stats.path_by_id_hits, - if stats.path_by_id_queries > 0 { stats.path_by_id_hits as f64 * 100.0 / stats.path_by_id_queries as f64 } else { 0.0 }, + if stats.path_by_id_queries > 0 { + stats.path_by_id_hits as f64 * 100.0 / stats.path_by_id_queries as f64 + } else { + 0.0 + }, stats.get_id_queries, stats.get_id_hits, - if stats.get_id_queries > 0 { stats.get_id_hits as f64 * 100.0 / stats.get_id_queries as f64 } else { 0.0 }, + if stats.get_id_queries > 0 { + stats.get_id_hits as f64 * 100.0 / stats.get_id_queries as f64 + } else { + 0.0 + }, stats.batch_operations, stats.batch_items_processed ); } }); } - + /// Adds a request to the pending queue for batch processing - async fn queue_path_to_id_request(&self, path: &StoragePath) -> Result, IdMappingError> { + async fn queue_path_to_id_request( + &self, + path: &StoragePath, + ) -> Result, IdMappingError> { let path_str = path.to_string(); - + // Check first in the cache { let cache = self.path_to_id_cache.read().await; @@ -177,42 +184,42 @@ impl IdMappingOptimizer { let mut stats = self.stats.write().await; stats.get_id_hits += 1; } - + return Ok(Some(id.clone())); } } - + // If not in cache, add to batch queue { let mut batch_queue = self.pending_batch.lock().await; batch_queue.path_to_id_requests.insert(path_str); } - + // Not found in cache, must be processed in batch Ok(None) } - + /// Processes pending requests in batch async fn process_batch(&self) -> Result { // Acquire permit for batch operation let _permit = self.batch_limiter.acquire().await.unwrap(); - + // Get pending requests let (path_requests, id_requests) = { let mut batch_queue = self.pending_batch.lock().await; - + let paths = std::mem::take(&mut batch_queue.path_to_id_requests); let ids = std::mem::take(&mut batch_queue.id_to_path_requests); - + (paths, ids) }; - + // Create results let mut result = BatchResult { path_to_id: HashMap::with_capacity(path_requests.len()), id_to_path: HashMap::with_capacity(id_requests.len()), }; - + // Process path->id requests in batch for path_str in path_requests { let path = StoragePath::from_string(&path_str); @@ -220,14 +227,14 @@ impl IdMappingOptimizer { Ok(id) => { result.path_to_id.insert(path_str.clone(), id.clone()); result.id_to_path.insert(id, path_str); - }, + } Err(e) => { error!("Error batch-processing path {}: {}", path_str, e); // Continue with remaining requests } } } - + // Process id->path requests in batch for id in id_requests { match self.base_service.get_path_by_id(&id).await { @@ -235,37 +242,37 @@ impl IdMappingOptimizer { let path_str = path.to_string(); result.id_to_path.insert(id.clone(), path_str.clone()); result.path_to_id.insert(path_str, id); - }, + } Err(e) => { error!("Error batch-processing ID {}: {}", id, e); // Continue with remaining requests } } } - + // Update cache with batch results { let mut path_cache = self.path_to_id_cache.write().await; let mut id_cache = self.id_to_path_cache.write().await; - + let now = Instant::now(); - + for (path, id) in &result.path_to_id { path_cache.insert(path.clone(), (id.clone(), now)); } - + for (id, path) in &result.id_to_path { id_cache.insert(id.clone(), (path.clone(), now)); } } - + // Update statistics { let mut stats = self.stats.write().await; stats.batch_operations += 1; stats.batch_items_processed += result.path_to_id.len() + result.id_to_path.len(); } - + // Save changes to disk in the background let service_clone = self.base_service.clone(); tokio::spawn(async move { @@ -273,36 +280,37 @@ impl IdMappingOptimizer { error!("Error saving ID mapping changes: {}", e); } }); - + Ok(result) } - + /// Forces processing of pending requests if there are enough async fn trigger_batch_if_needed(&self, min_batch_size: usize) -> Result<(), IdMappingError> { // Check if there are enough pending requests let should_process = { let batch_queue = self.pending_batch.lock().await; - batch_queue.path_to_id_requests.len() + batch_queue.id_to_path_requests.len() >= min_batch_size + batch_queue.path_to_id_requests.len() + batch_queue.id_to_path_requests.len() + >= min_batch_size }; - + // Process if necessary if should_process { self.process_batch().await?; } - + Ok(()) } - + /// Preload a set of paths to get their IDs in batch pub async fn preload_paths(&self, paths: Vec) -> Result<(), IdMappingError> { // Only proceed if there are paths to load if paths.is_empty() { return Ok(()); } - + // Paths we need to load (those not in cache) let mut paths_to_load = Vec::new(); - + // Check cache first { let cache = self.path_to_id_cache.read().await; @@ -313,12 +321,12 @@ impl IdMappingOptimizer { } } } - + // If all were in cache, finish if paths_to_load.is_empty() { return Ok(()); } - + // Add paths to queue for batch processing { let mut batch_queue = self.pending_batch.lock().await; @@ -326,23 +334,23 @@ impl IdMappingOptimizer { batch_queue.path_to_id_requests.insert(path); } } - + // Execute batch processing immediately self.process_batch().await?; - + Ok(()) } - + /// Preload a set of IDs to get their paths in batch pub async fn preload_ids(&self, ids: Vec) -> Result<(), IdMappingError> { // Only proceed if there are IDs to load if ids.is_empty() { return Ok(()); } - + // IDs we need to load (those not in cache) let mut ids_to_load = Vec::new(); - + // Check cache first { let cache = self.id_to_path_cache.read().await; @@ -352,12 +360,12 @@ impl IdMappingOptimizer { } } } - + // If all were in cache, finish if ids_to_load.is_empty() { return Ok(()); } - + // Add IDs to queue for batch processing { let mut batch_queue = self.pending_batch.lock().await; @@ -365,10 +373,10 @@ impl IdMappingOptimizer { batch_queue.id_to_path_requests.insert(id); } } - + // Execute batch processing immediately self.process_batch().await?; - + Ok(()) } } @@ -381,9 +389,9 @@ impl IdMappingPort for IdMappingOptimizer { let mut stats = self.stats.write().await; stats.get_id_queries += 1; } - + let path_str = path.to_string(); - + // Check cache first { let cache = self.path_to_id_cache.read().await; @@ -393,55 +401,61 @@ impl IdMappingPort for IdMappingOptimizer { let mut stats = self.stats.write().await; stats.get_id_hits += 1; } - + return Ok(id.clone()); } } - + // If not in cache, try adding to batch queue first let queued_result = self.queue_path_to_id_request(path).await?; if let Some(id) = queued_result { return Ok(id); } - + // Trigger batch processing if enough items accumulated self.trigger_batch_if_needed(20).await?; - + // Try to get from the base service let id = self.base_service.get_or_create_id(path).await?; - + // Update cache with the new ID { let mut path_cache = self.path_to_id_cache.write().await; let mut id_cache = self.id_to_path_cache.write().await; - + let now = Instant::now(); - + // Control cache size if path_cache.len() >= MAX_CACHE_SIZE { - warn!("Path-to-ID cache size reached limit ({}), clearing oldest entries", MAX_CACHE_SIZE); + warn!( + "Path-to-ID cache size reached limit ({}), clearing oldest entries", + MAX_CACHE_SIZE + ); path_cache.clear(); } - + if id_cache.len() >= MAX_CACHE_SIZE { - warn!("ID-to-path cache size reached limit ({}), clearing oldest entries", MAX_CACHE_SIZE); + warn!( + "ID-to-path cache size reached limit ({}), clearing oldest entries", + MAX_CACHE_SIZE + ); id_cache.clear(); } - + path_cache.insert(path_str.clone(), (id.clone(), now)); id_cache.insert(id.clone(), (path_str, now)); } - + Ok(id) } - + async fn get_path_by_id(&self, id: &str) -> Result { // Update statistics { let mut stats = self.stats.write().await; stats.path_by_id_queries += 1; } - + // Check first in the cache { let cache = self.id_to_path_cache.read().await; @@ -451,92 +465,98 @@ impl IdMappingPort for IdMappingOptimizer { let mut stats = self.stats.write().await; stats.path_by_id_hits += 1; } - + return Ok(StoragePath::from_string(path_str)); } } - + // Get from the base service let path = self.base_service.get_path_by_id(id).await?; - + // Update cache { let mut id_cache = self.id_to_path_cache.write().await; let mut path_cache = self.path_to_id_cache.write().await; - + let now = Instant::now(); let path_str = path.to_string(); - + // Control cache size if id_cache.len() >= MAX_CACHE_SIZE { - warn!("ID-to-path cache size reached limit ({}), clearing oldest entries", MAX_CACHE_SIZE); + warn!( + "ID-to-path cache size reached limit ({}), clearing oldest entries", + MAX_CACHE_SIZE + ); id_cache.clear(); } - + if path_cache.len() >= MAX_CACHE_SIZE { - warn!("Path-to-ID cache size reached limit ({}), clearing oldest entries", MAX_CACHE_SIZE); + warn!( + "Path-to-ID cache size reached limit ({}), clearing oldest entries", + MAX_CACHE_SIZE + ); path_cache.clear(); } - + id_cache.insert(id.to_string(), (path_str.clone(), now)); path_cache.insert(path_str, (id.to_string(), now)); } - + Ok(path) } - + async fn update_path(&self, id: &str, new_path: &StoragePath) -> Result<(), DomainError> { // Invalidate cache for this ID { let mut id_cache = self.id_to_path_cache.write().await; let mut path_cache = self.path_to_id_cache.write().await; - + // Remove the ID entry if let Some((old_path, _)) = id_cache.remove(id) { path_cache.remove(&old_path); } } - + // Update in the base service let result = self.base_service.update_path(id, new_path).await?; - + // Update cache with new mapping { let mut id_cache = self.id_to_path_cache.write().await; let mut path_cache = self.path_to_id_cache.write().await; - + let now = Instant::now(); let path_str = new_path.to_string(); - + id_cache.insert(id.to_string(), (path_str.clone(), now)); path_cache.insert(path_str, (id.to_string(), now)); } - + Ok(result) } - + async fn remove_id(&self, id: &str) -> Result<(), DomainError> { // Invalidate cache for this ID { let mut id_cache = self.id_to_path_cache.write().await; let mut path_cache = self.path_to_id_cache.write().await; - + // Remove the ID entry if let Some((path, _)) = id_cache.remove(id) { path_cache.remove(&path); } } - + // Remove from the base service self.base_service.remove_id(id).await?; - + Ok(()) } - + async fn save_changes(&self) -> Result<(), DomainError> { // Delegate to the base service self.base_service.save_changes().await?; - + Ok(()) } } @@ -545,90 +565,99 @@ impl IdMappingPort for IdMappingOptimizer { mod tests { use super::*; use tempfile::tempdir; - + async fn create_test_service() -> (Arc, Arc) { let temp_dir = tempdir().unwrap(); let map_path = temp_dir.path().join("id_map.json"); - + let base_service = Arc::new(IdMappingService::new(map_path).await.unwrap()); let optimizer = Arc::new(IdMappingOptimizer::new(base_service.clone())); - + (base_service, optimizer) } - + #[tokio::test] async fn test_basic_caching() { let (_, optimizer) = create_test_service().await; - + let path = StoragePath::from_string("/test/file.txt"); - + // First call should use the base service let id = optimizer.get_or_create_id(&path).await.unwrap(); assert!(!id.is_empty(), "ID should not be empty"); - + // Second call should use cache let id2 = optimizer.get_or_create_id(&path).await.unwrap(); assert_eq!(id, id2, "Same path should return same ID"); - + // Verify cache statistics let stats = optimizer.get_stats().await; assert_eq!(stats.get_id_queries, 2, "Should have 2 queries"); assert_eq!(stats.get_id_hits, 1, "Should have 1 hit"); } - + #[tokio::test] async fn test_batch_processing() { let (_, optimizer) = create_test_service().await; - + // Create a batch of paths let mut paths = Vec::new(); for i in 0..50 { - paths.push(StoragePath::from_string(&format!("/test/batch/file{}.txt", i))); + paths.push(StoragePath::from_string(&format!( + "/test/batch/file{}.txt", + i + ))); } - + // Preload the paths optimizer.preload_paths(paths.clone()).await.unwrap(); - + // Verify all are in cache for path in &paths { let id = optimizer.get_or_create_id(path).await.unwrap(); assert!(!id.is_empty(), "ID should be available for path"); } - + // Verify statistics let stats = optimizer.get_stats().await; assert_eq!(stats.batch_operations, 1, "Should have 1 batch operation"); - assert!(stats.batch_items_processed >= 50, "Should have processed at least 50 items"); - + assert!( + stats.batch_items_processed >= 50, + "Should have processed at least 50 items" + ); + // Verify all subsequent queries are cache hits - assert_eq!(stats.get_id_hits, 50, "All subsequente queries should be cache hits"); + assert_eq!( + stats.get_id_hits, 50, + "All subsequente queries should be cache hits" + ); } - + #[tokio::test] async fn test_cache_cleanup() { let (_, optimizer) = create_test_service().await; - + // Create some entries let path = StoragePath::from_string("/test/cleanup.txt"); let id = optimizer.get_or_create_id(&path).await.unwrap(); - + // Verify initial statistics { let stats = optimizer.get_stats().await; assert_eq!(stats.get_id_queries, 1, "Should have 1 query"); assert_eq!(stats.get_id_hits, 0, "Should have 0 hits"); } - + // Run cleanup (should not remove anything yet) optimizer.cleanup_cache().await; - + // Verify cache is still working let id2 = optimizer.get_or_create_id(&path).await.unwrap(); assert_eq!(id, id2, "Cache should still work after cleanup"); - + { let stats = optimizer.get_stats().await; assert_eq!(stats.get_id_hits, 1, "Should have 1 hit after cleanup"); } } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/id_mapping_service.rs b/src/infrastructure/services/id_mapping_service.rs index 367919a0..19eb81cf 100644 --- a/src/infrastructure/services/id_mapping_service.rs +++ b/src/infrastructure/services/id_mapping_service.rs @@ -1,32 +1,32 @@ -use std::path::PathBuf; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use tokio::sync::{RwLock, Mutex}; +use std::path::PathBuf; use tokio::fs; +use tokio::sync::{Mutex, RwLock}; use tokio::time; use uuid::Uuid; -use serde::{Serialize, Deserialize}; -use async_trait::async_trait; -use crate::domain::services::path_service::StoragePath; -use crate::common::errors::{DomainError, ErrorKind}; use crate::application::ports::outbound::IdMappingPort; use crate::common::config::TimeoutConfig; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::services::path_service::StoragePath; /// Specific error for the ID mapping service #[derive(Debug, thiserror::Error)] pub enum IdMappingError { #[error("ID not found: {0}")] NotFound(String), - + #[error("IO error: {0}")] IoError(#[from] std::io::Error), - + #[error("Timeout error: {0}")] Timeout(String), - + #[error("Serialization error: {0}")] SerializationError(#[from] serde_json::Error), - + #[error("Other error: {0}")] Other(String), } @@ -39,21 +39,22 @@ impl From for DomainError { IdMappingError::IoError(e) => DomainError::new( ErrorKind::InternalError, "IdMapping", - format!("IO error: {}", e) - ).with_source(e), - IdMappingError::Timeout(msg) => DomainError::timeout( - "IdMapping", - format!("Timeout: {}", msg) - ), + format!("IO error: {}", e), + ) + .with_source(e), + IdMappingError::Timeout(msg) => { + DomainError::timeout("IdMapping", format!("Timeout: {}", msg)) + } IdMappingError::SerializationError(e) => DomainError::new( ErrorKind::InternalError, "IdMapping", - format!("Serialization error: {}", e) - ).with_source(e), + format!("Serialization error: {}", e), + ) + .with_source(e), IdMappingError::Other(msg) => DomainError::new( ErrorKind::InternalError, "IdMapping", - format!("Other error: {}", msg) + format!("Other error: {}", msg), ), } } @@ -64,7 +65,7 @@ impl From for DomainError { struct IdMap { path_to_id: HashMap, id_to_path: HashMap, // Field for efficient bidirectional lookup - version: u32, // Version to detect changes + version: u32, // Version to detect changes } /// Service to manage mappings between paths and unique IDs @@ -81,7 +82,7 @@ impl IdMappingService { pub async fn new(map_path: PathBuf) -> Result { let timeouts = TimeoutConfig::default(); let id_map = Self::load_id_map(&map_path, &timeouts).await?; - + Ok(Self { map_path, id_map: RwLock::new(id_map), @@ -90,7 +91,7 @@ impl IdMappingService { pending_save: RwLock::new(false), }) } - + /// Creates an in-memory ID mapping service (for testing) /// /// Similar functionality as new_in_memory but with a simpler signature for dummy use @@ -103,7 +104,7 @@ impl IdMappingService { pending_save: RwLock::new(false), } } - + /// Creates an in-memory ID mapping service (for testing - original version) pub fn new_in_memory() -> Self { Self { @@ -114,19 +115,30 @@ impl IdMappingService { pending_save: RwLock::new(false), } } - + /// Loads the ID map from disk with robust error handling - async fn load_id_map(map_path: &PathBuf, timeouts: &TimeoutConfig) -> Result { + async fn load_id_map( + map_path: &PathBuf, + timeouts: &TimeoutConfig, + ) -> Result { if map_path.exists() { // Try to read with timeout to avoid indefinite blocking - let read_result = time::timeout( - timeouts.lock_timeout(), - fs::read_to_string(map_path) - ).await - .map_err(|_| DomainError::timeout("IdMapping", format!("Timeout reading ID map from {}", map_path.display())))?; - - let content = read_result.map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to read ID map from {}: {}", map_path.display(), e)))?; - + let read_result = time::timeout(timeouts.lock_timeout(), fs::read_to_string(map_path)) + .await + .map_err(|_| { + DomainError::timeout( + "IdMapping", + format!("Timeout reading ID map from {}", map_path.display()), + ) + })?; + + let content = read_result.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!("Failed to read ID map from {}: {}", map_path.display(), e), + ) + })?; + // Parse the JSON match serde_json::from_str::(&content) { Ok(mut map) => { @@ -139,11 +151,14 @@ impl IdMappingService { } tracing::info!("Rebuilt inverse mapping with {} entries", rebuild_count); } - - tracing::info!("Loaded ID map with {} entries (version: {})", - map.path_to_id.len(), map.version); + + tracing::info!( + "Loaded ID map with {} entries (version: {})", + map.path_to_id.len(), + map.version + ); return Ok(map); - }, + } Err(e) => { tracing::error!("Error parsing ID map: {}", e); // Try to backup the corrupted file @@ -153,7 +168,7 @@ impl IdMappingService { } else { tracing::info!("Backed up corrupted ID map to {}", backup_path.display()); } - + tracing::info!("Creating new empty map after error"); return Ok(IdMap { path_to_id: HashMap::new(), @@ -163,7 +178,7 @@ impl IdMappingService { } } } - + // Return an empty map if the file doesn't exist and create the file tracing::info!("No existing ID map found, creating new empty map"); let empty_map = IdMap { @@ -171,217 +186,259 @@ impl IdMappingService { id_to_path: HashMap::new(), version: 1, // Start with version 1 }; - + // Ensure directory exists if let Some(parent) = map_path.parent() && !parent.exists() - && let Err(e) = fs::create_dir_all(parent).await { - tracing::error!("Failed to create directory for ID map: {}", e); - } - + && let Err(e) = fs::create_dir_all(parent).await + { + tracing::error!("Failed to create directory for ID map: {}", e); + } + // Write empty map to file (best-effort: the in-memory map is valid even if disk write fails) match serde_json::to_string_pretty(&empty_map) { Ok(json) => { if let Err(e) = fs::write(map_path, json).await { - tracing::warn!("Could not write initial empty ID map (will retry on next save): {}", e); + tracing::warn!( + "Could not write initial empty ID map (will retry on next save): {}", + e + ); } else { tracing::info!("Created initial empty ID map at {}", map_path.display()); } - }, + } Err(e) => { tracing::error!("Failed to serialize empty ID map: {}", e); } } - + Ok(empty_map) } - + /// Saves the ID map to disk safely async fn save_id_map(&self) -> Result<(), DomainError> { // Acquire exclusive lock for saving - let _lock = time::timeout( - self.timeouts.lock_timeout(), - self.save_mutex.lock() - ).await - .map_err(|_| DomainError::timeout("IdMapping", "Timeout acquiring save lock for ID mapping"))?; - + let _lock = time::timeout(self.timeouts.lock_timeout(), self.save_mutex.lock()) + .await + .map_err(|_| { + DomainError::timeout("IdMapping", "Timeout acquiring save lock for ID mapping") + })?; + // Create JSON with read lock to minimize lock hold time let json = { - let mut map = time::timeout( - self.timeouts.lock_timeout(), - self.id_map.write() - ).await - .map_err(|_| DomainError::timeout("IdMapping", "Timeout acquiring write lock for ID mapping"))?; - + let mut map = time::timeout(self.timeouts.lock_timeout(), self.id_map.write()) + .await + .map_err(|_| { + DomainError::timeout("IdMapping", "Timeout acquiring write lock for ID mapping") + })?; + // Increment version only if there are pending changes to save let pending = *self.pending_save.read().await; if pending { map.version += 1; tracing::debug!("Incrementing ID map version to {}", map.version); } - + // Use serde with reasonably safe defaults - serde_json::to_string_pretty(&*map) - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to serialize ID map to JSON: {}", e)))? + serde_json::to_string_pretty(&*map).map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!("Failed to serialize ID map to JSON: {}", e), + ) + })? }; - + // Write to a temporary file first to avoid corruption let temp_path = self.map_path.with_extension("json.tmp"); - fs::write(&temp_path, &json).await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to write temporary ID map to {}: {}", temp_path.display(), e)))?; - + fs::write(&temp_path, &json).await.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!( + "Failed to write temporary ID map to {}: {}", + temp_path.display(), + e + ), + ) + })?; + // Perform the atomic rename - fs::rename(&temp_path, &self.map_path).await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to rename temporary ID map to {}: {}", self.map_path.display(), e)))?; - + fs::rename(&temp_path, &self.map_path).await.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!( + "Failed to rename temporary ID map to {}: {}", + self.map_path.display(), + e + ), + ) + })?; + // Reset pending flag { let mut pending = self.pending_save.write().await; *pending = false; } - + tracing::info!("Saved ID map successfully to {}", self.map_path.display()); Ok(()) } - + /// Generates a unique ID fn generate_id(&self) -> String { Uuid::new_v4().to_string() } - + /// Marks changes as pending async fn mark_pending(&self) { let mut pending = self.pending_save.write().await; *pending = true; } - + /// Gets the ID for a path or generates a new one if it doesn't exist pub async fn get_or_create_id(&self, path: &StoragePath) -> Result { let path_str = path.to_string(); - + // First attempt with read lock (more efficient) { - let read_result = match time::timeout( - self.timeouts.lock_timeout(), - self.id_map.read() - ).await { - Ok(guard) => guard, - Err(_) => return Err(IdMappingError::Timeout("Timeout acquiring read lock for ID mapping".to_string())), - }; - + let read_result = + match time::timeout(self.timeouts.lock_timeout(), self.id_map.read()).await { + Ok(guard) => guard, + Err(_) => { + return Err(IdMappingError::Timeout( + "Timeout acquiring read lock for ID mapping".to_string(), + )); + } + }; + if let Some(id) = read_result.path_to_id.get(&path_str) { return Ok(id.clone()); } } - + // If not found, acquire write lock - let write_result = match time::timeout( - self.timeouts.lock_timeout(), - self.id_map.write() - ).await { - Ok(guard) => guard, - Err(_) => return Err(IdMappingError::Timeout("Timeout acquiring write lock for ID mapping".to_string())), - }; - + let write_result = + match time::timeout(self.timeouts.lock_timeout(), self.id_map.write()).await { + Ok(guard) => guard, + Err(_) => { + return Err(IdMappingError::Timeout( + "Timeout acquiring write lock for ID mapping".to_string(), + )); + } + }; + let mut map = write_result; - + // Check again (it could have been added while we were waiting for the lock) if let Some(id) = map.path_to_id.get(&path_str) { return Ok(id.clone()); } - + // Generate a new ID and store it let id = self.generate_id(); map.path_to_id.insert(path_str.clone(), id.clone()); map.id_to_path.insert(id.clone(), path_str); - + // Mark as pending for saving drop(map); // Release the write lock before acquiring another self.mark_pending().await; - + tracing::debug!("Created new ID mapping: {} -> {}", path.to_string(), id); - + Ok(id) } - + /// Gets a path by its ID with timeout handling pub async fn get_path_by_id(&self, id: &str) -> Result { - let read_result = match time::timeout( - self.timeouts.lock_timeout(), - self.id_map.read() - ).await { - Ok(guard) => guard, - Err(_) => return Err(IdMappingError::Timeout("Timeout acquiring read lock for ID lookup".to_string())), - }; - + let read_result = + match time::timeout(self.timeouts.lock_timeout(), self.id_map.read()).await { + Ok(guard) => guard, + Err(_) => { + return Err(IdMappingError::Timeout( + "Timeout acquiring read lock for ID lookup".to_string(), + )); + } + }; + if let Some(path_str) = read_result.id_to_path.get(id) { return Ok(StoragePath::from_string(path_str)); } - + Err(IdMappingError::NotFound(id.to_string())) } - + /// Updates the mapping of an existing ID to a new path - pub async fn update_path(&self, id: &str, new_path: &StoragePath) -> Result<(), IdMappingError> { - let write_result = match time::timeout( - self.timeouts.lock_timeout(), - self.id_map.write() - ).await { - Ok(guard) => guard, - Err(_) => return Err(IdMappingError::Timeout("Timeout acquiring write lock for ID update".to_string())), - }; - + pub async fn update_path( + &self, + id: &str, + new_path: &StoragePath, + ) -> Result<(), IdMappingError> { + let write_result = + match time::timeout(self.timeouts.lock_timeout(), self.id_map.write()).await { + Ok(guard) => guard, + Err(_) => { + return Err(IdMappingError::Timeout( + "Timeout acquiring write lock for ID update".to_string(), + )); + } + }; + let mut map = write_result; - + // Find the previous path to remove it if let Some(old_path) = map.id_to_path.get(id).cloned() { map.path_to_id.remove(&old_path); - + // Register the new path let new_path_str = new_path.to_string(); map.path_to_id.insert(new_path_str.clone(), id.to_string()); map.id_to_path.insert(id.to_string(), new_path_str); - + // Mark as pending drop(map); // Release the write lock before acquiring another self.mark_pending().await; - - tracing::debug!("Updated path mapping for ID {}: {} -> {}", - id, old_path, new_path.to_string()); - + + tracing::debug!( + "Updated path mapping for ID {}: {} -> {}", + id, + old_path, + new_path.to_string() + ); + Ok(()) } else { Err(IdMappingError::NotFound(id.to_string())) } } - + /// Removes an ID from the map pub async fn remove_id(&self, id: &str) -> Result<(), IdMappingError> { - let write_result = match time::timeout( - self.timeouts.lock_timeout(), - self.id_map.write() - ).await { - Ok(guard) => guard, - Err(_) => return Err(IdMappingError::Timeout("Timeout acquiring write lock for ID removal".to_string())), - }; - + let write_result = + match time::timeout(self.timeouts.lock_timeout(), self.id_map.write()).await { + Ok(guard) => guard, + Err(_) => { + return Err(IdMappingError::Timeout( + "Timeout acquiring write lock for ID removal".to_string(), + )); + } + }; + let mut map = write_result; - + // Find the path to remove it if let Some(path) = map.id_to_path.remove(id) { map.path_to_id.remove(&path); - + // Mark as pending drop(map); // Release the write lock before acquiring another self.mark_pending().await; - + tracing::debug!("Removed ID mapping: {} -> {}", id, path); Ok(()) } else { Err(IdMappingError::NotFound(id.to_string())) } } - + /// Saves pending changes to disk immediately, without debounce pub async fn save_pending_changes(&self) -> Result<(), IdMappingError> { // Check if there are pending changes @@ -391,50 +448,64 @@ impl IdMappingService { return Ok(()); } } - + // Save immediately (without debounce or spawn) match self.save_id_map().await { Ok(_) => { - tracing::info!("ID mappings saved successfully to disk at {}", self.map_path.display()); - + tracing::info!( + "ID mappings saved successfully to disk at {}", + self.map_path.display() + ); + // Explicitly verify that the file exists and has size match std::fs::metadata(&self.map_path) { Ok(metadata) => { if metadata.len() > 0 { - tracing::info!("Verified saved map file exists with size: {} bytes", metadata.len()); + tracing::info!( + "Verified saved map file exists with size: {} bytes", + metadata.len() + ); } else { - tracing::warn!("Map file exists but has zero size - this might cause issues"); + tracing::warn!( + "Map file exists but has zero size - this might cause issues" + ); } - }, + } Err(e) => { tracing::error!("Failed to verify saved map file: {}", e); // Try a second save if verification fails if let Err(retry_err) = self.save_id_map().await { tracing::error!("Second save attempt also failed: {}", retry_err); - return Err(IdMappingError::IoError(std::io::Error::other( - format!("Failed to verify and retry save: {}", retry_err) - ))); + return Err(IdMappingError::IoError(std::io::Error::other(format!( + "Failed to verify and retry save: {}", + retry_err + )))); } tracing::info!("Second save attempt succeeded"); } } - + Ok(()) - }, + } Err(e) => { - tracing::error!("Failed to save ID map to {}: {}", self.map_path.display(), e); + tracing::error!( + "Failed to save ID map to {}: {}", + self.map_path.display(), + e + ); // Try a second save with delay in case of error tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; match self.save_id_map().await { Ok(_) => { tracing::info!("Second save attempt succeeded after initial failure"); Ok(()) - }, + } Err(retry_e) => { tracing::error!("Second save attempt also failed: {}", retry_e); - Err(IdMappingError::IoError(std::io::Error::other( - format!("Failed to save ID mappings after retry: {}", retry_e) - ))) + Err(IdMappingError::IoError(std::io::Error::other(format!( + "Failed to save ID mappings after retry: {}", + retry_e + )))) } } } @@ -446,32 +517,58 @@ impl IdMappingService { impl IdMappingPort for IdMappingService { /// Gets the ID for a path or generates a new one if it doesn't exist async fn get_or_create_id(&self, path: &StoragePath) -> Result { - self.get_or_create_id(path).await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to get or create ID for path: {}: {}", path.to_string(), e))) + self.get_or_create_id(path).await.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!( + "Failed to get or create ID for path: {}: {}", + path.to_string(), + e + ), + ) + }) } - + /// Gets a path by its ID with timeout handling async fn get_path_by_id(&self, id: &str) -> Result { - self.get_path_by_id(id).await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to get path for ID: {}: {}", id, e))) + self.get_path_by_id(id).await.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!("Failed to get path for ID: {}: {}", id, e), + ) + }) } - + /// Updates the mapping of an existing ID to a new path async fn update_path(&self, id: &str, new_path: &StoragePath) -> Result<(), DomainError> { - self.update_path(id, new_path).await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to update path for ID: {} to {}: {}", id, new_path.to_string(), e))) + self.update_path(id, new_path).await.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!( + "Failed to update path for ID: {} to {}: {}", + id, + new_path.to_string(), + e + ), + ) + }) } - + /// Removes an ID from the map async fn remove_id(&self, id: &str) -> Result<(), DomainError> { - self.remove_id(id).await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to remove ID: {}: {}", id, e))) + self.remove_id(id).await.map_err(|e| { + DomainError::internal_error("IdMapping", format!("Failed to remove ID: {}: {}", id, e)) + }) } - + /// Saves pending changes to disk async fn save_changes(&self) -> Result<(), DomainError> { - self.save_pending_changes().await - .map_err(|e| DomainError::internal_error("IdMapping", format!("Failed to save pending ID mapping changes: {}", e))) + self.save_pending_changes().await.map_err(|e| { + DomainError::internal_error( + "IdMapping", + format!("Failed to save pending ID mapping changes: {}", e), + ) + }) } } @@ -501,7 +598,7 @@ impl Clone for IdMappingService { Self { map_path: self.map_path.clone(), id_map: RwLock::new(IdMap::default()), // This is not used in the async task - save_mutex: Mutex::new(()), // Neither is this + save_mutex: Mutex::new(()), // Neither is this timeouts: self.timeouts.clone(), pending_save: RwLock::new(false), } @@ -513,100 +610,103 @@ mod tests { use super::*; use std::time::Duration; use tempfile::tempdir; - + #[tokio::test] async fn test_get_or_create_id() { let temp_dir = tempdir().unwrap(); let map_path = temp_dir.path().join("id_map.json"); - + let service = IdMappingService::new(map_path).await.unwrap(); - + let path = StoragePath::from_string("/test/file.txt"); let id = service.get_or_create_id(&path).await.unwrap(); - + assert!(!id.is_empty(), "ID should not be empty"); - + // Verify that the same ID is returned for the same path let id2 = service.get_or_create_id(&path).await.unwrap(); assert_eq!(id, id2, "Same path should return same ID"); } - + #[tokio::test] async fn test_update_path() { let temp_dir = tempdir().unwrap(); let map_path = temp_dir.path().join("id_map.json"); - + let service = IdMappingService::new(map_path).await.unwrap(); - + let old_path = StoragePath::from_string("/test/old.txt"); let id = service.get_or_create_id(&old_path).await.unwrap(); - + let new_path = StoragePath::from_string("/test/new.txt"); service.update_path(&id, &new_path).await.unwrap(); - + let retrieved_path = service.get_path_by_id(&id).await.unwrap(); assert_eq!(retrieved_path, new_path, "Path should be updated"); } - + #[tokio::test] async fn test_save_and_load() { let temp_dir = tempdir().unwrap(); let map_path = temp_dir.path().join("id_map.json"); - + // Create and populate the service let service = IdMappingService::new(map_path.clone()).await.unwrap(); - + let path1 = StoragePath::from_string("/test/file1.txt"); let path2 = StoragePath::from_string("/test/file2.txt"); let id1 = service.get_or_create_id(&path1).await.unwrap(); let id2 = service.get_or_create_id(&path2).await.unwrap(); - + // Save changes service.save_pending_changes().await.unwrap(); - + // Wait to ensure the async save completes tokio::time::sleep(Duration::from_millis(500)).await; - + // Create a new service that should load the same map let service2 = IdMappingService::new(map_path).await.unwrap(); - + // Verify that the IDs match let loaded_id1 = service2.get_or_create_id(&path1).await.unwrap(); let loaded_id2 = service2.get_or_create_id(&path2).await.unwrap(); - + assert_eq!(id1, loaded_id1, "ID1 should be preserved"); assert_eq!(id2, loaded_id2, "ID2 should be preserved"); } - + #[tokio::test] async fn test_concurrent_operations() { use futures::future::join_all; - + let temp_dir = tempdir().unwrap(); let map_path = temp_dir.path().join("id_map.json"); - + let service = std::sync::Arc::new(IdMappingService::new(map_path).await.unwrap()); - + // Create multiple tasks that attempt simultaneous access let mut tasks = Vec::new(); for i in 0..100 { let path = StoragePath::from_string(&format!("/test/concurrent/file{}.txt", i)); let service_clone = service.clone(); - + tasks.push(tokio::spawn(async move { service_clone.get_or_create_id(&path).await })); } - + // Wait for all to finish let results = join_all(tasks).await; - + // Verify that all succeeded for result in results { - assert!(result.unwrap().is_ok(), "Concurrent operations should succeed"); + assert!( + result.unwrap().is_ok(), + "Concurrent operations should succeed" + ); } - + // Save changes service.save_pending_changes().await.unwrap(); } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/image_transcode_service.rs b/src/infrastructure/services/image_transcode_service.rs index 2c69f505..a6193324 100644 --- a/src/infrastructure/services/image_transcode_service.rs +++ b/src/infrastructure/services/image_transcode_service.rs @@ -1,459 +1,484 @@ -//! Image Transcoding Service - WebP On-Demand Conversion -//! -//! Automatically transcodes images to WebP format when the browser supports it, -//! reducing bandwidth by 30-50% compared to JPEG/PNG. -//! -//! Features: -//! - Detects browser WebP support via Accept header -//! - Caches transcoded versions to avoid re-conversion -//! - Supports JPEG, PNG, GIF → WebP conversion -//! - Configurable quality settings -//! - Falls back to original if conversion fails - -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio::fs; -use bytes::Bytes; -use lru::LruCache; -use std::num::NonZeroUsize; -use image::{ImageFormat, DynamicImage}; -use async_trait::async_trait; - -use crate::application::ports::transcode_ports::{ - ImageTranscodePort, - OutputFormat as PortOutputFormat, - TranscodeStatsDto, -}; -use crate::domain::errors::{DomainError, ErrorKind}; - -/// Maximum file size for transcoding (5MB - larger files stream directly) -pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024; - -/// Cache key for transcoded images -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct TranscodeKey { - file_id: String, - format: OutputFormat, -} - -/// Supported output formats -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum OutputFormat { - WebP, - // Future: AVIF, JPEG-XL -} - -impl OutputFormat { - pub fn extension(&self) -> &'static str { - match self { - OutputFormat::WebP => "webp", - } - } - - pub fn mime_type(&self) -> &'static str { - match self { - OutputFormat::WebP => "image/webp", - } - } -} - -/// Result of checking browser support -#[derive(Debug)] -pub struct BrowserCapabilities { - pub supports_webp: bool, - pub supports_avif: bool, -} - -impl BrowserCapabilities { - /// Parse Accept header to determine browser image format support - pub fn from_accept_header(accept: Option<&str>) -> Self { - let accept = accept.unwrap_or(""); - Self { - supports_webp: accept.contains("image/webp"), - supports_avif: accept.contains("image/avif"), - } - } - - /// Get the best output format for this browser - pub fn best_format(&self) -> Option { - // WebP has best support currently - if self.supports_webp { - Some(OutputFormat::WebP) - } else { - None - } - } -} - -/// Image Transcoding Service -pub struct ImageTranscodeService { - /// Cache directory for transcoded images - cache_dir: PathBuf, - /// In-memory LRU cache for hot transcoded images - memory_cache: Arc>>, - /// Maximum memory cache size in bytes - max_memory_bytes: usize, - /// Current memory usage - current_memory_bytes: Arc>, - /// Statistics - stats: Arc>, -} - -/// Transcoding statistics -#[derive(Debug, Default, Clone)] -pub struct TranscodeStats { - pub cache_hits: u64, - pub disk_hits: u64, - pub transcodes: u64, - pub bytes_saved: u64, - pub transcode_errors: u64, -} - -impl ImageTranscodeService { - /// Create new transcoding service - pub fn new(storage_root: &Path, max_cache_entries: usize, max_memory_bytes: usize) -> Self { - let cache_dir = storage_root.join(".transcoded"); - - Self { - cache_dir, - memory_cache: Arc::new(RwLock::new(LruCache::new( - NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()) - ))), - max_memory_bytes, - current_memory_bytes: Arc::new(RwLock::new(0)), - stats: Arc::new(RwLock::new(TranscodeStats::default())), - } - } - - /// Initialize the service (create cache directories) - pub async fn initialize(&self) -> std::io::Result<()> { - fs::create_dir_all(&self.cache_dir).await?; - fs::create_dir_all(self.cache_dir.join("webp")).await?; - tracing::info!("🖼️ Image transcode service initialized at {:?}", self.cache_dir); - Ok(()) - } - - /// Check if a mime type can be transcoded - pub fn can_transcode(mime_type: &str) -> bool { - matches!( - mime_type, - "image/jpeg" | "image/jpg" | "image/png" | "image/gif" - ) - } - - /// Check if transcoding should be attempted based on file size and type - pub fn should_transcode(mime_type: &str, file_size: u64) -> bool { - Self::can_transcode(mime_type) && file_size <= MAX_TRANSCODE_SIZE - } - - /// Get transcoded version of an image - /// Returns (content, mime_type, was_transcoded) - pub async fn get_transcoded( - &self, - file_id: &str, - original_content: &[u8], - original_mime: &str, - target_format: OutputFormat, - ) -> Result<(Bytes, String, bool), String> { - let key = TranscodeKey { - file_id: file_id.to_string(), - format: target_format, - }; - - // Check memory cache first - { - let mut cache = self.memory_cache.write().await; - if let Some(cached) = cache.get(&key) { - let mut stats = self.stats.write().await; - stats.cache_hits += 1; - tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id); - return Ok((cached.clone(), target_format.mime_type().to_string(), true)); - } - } - - // Check disk cache - let cache_path = self.get_cache_path(file_id, target_format); - if cache_path.exists() { - match fs::read(&cache_path).await { - Ok(data) => { - let content = Bytes::from(data); - - // Store in memory cache - self.cache_in_memory(&key, content.clone()).await; - - let mut stats = self.stats.write().await; - stats.disk_hits += 1; - tracing::debug!("💾 Transcode disk cache HIT: {}", file_id); - return Ok((content, target_format.mime_type().to_string(), true)); - }, - Err(e) => { - tracing::warn!("Failed to read cached transcode: {}", e); - } - } - } - - // Need to transcode - let transcoded = self.transcode_image(original_content, original_mime, target_format)?; - let transcoded_bytes = Bytes::from(transcoded.clone()); - - // Calculate savings - let original_size = original_content.len(); - let transcoded_size = transcoded_bytes.len(); - let saved = original_size.saturating_sub(transcoded_size); - - // Only use transcoded if it's actually smaller - if transcoded_size >= original_size { - tracing::debug!( - "⚠️ Transcode not beneficial for {}: {} -> {} bytes", - file_id, original_size, transcoded_size - ); - return Ok((Bytes::from(original_content.to_vec()), original_mime.to_string(), false)); - } - - // Save to disk cache (async, don't wait) - let cache_path_clone = cache_path.clone(); - let transcoded_clone = transcoded.clone(); - tokio::spawn(async move { - if let Some(parent) = cache_path_clone.parent() { - let _ = fs::create_dir_all(parent).await; - } - if let Err(e) = fs::write(&cache_path_clone, &transcoded_clone).await { - tracing::warn!("Failed to cache transcoded image: {}", e); - } - }); - - // Store in memory cache - self.cache_in_memory(&key, transcoded_bytes.clone()).await; - - // Update stats - { - let mut stats = self.stats.write().await; - stats.transcodes += 1; - stats.bytes_saved += saved as u64; - } - - tracing::info!( - "✨ Transcoded {}: {} -> {} bytes ({:.1}% smaller)", - file_id, - original_size, - transcoded_size, - (1.0 - transcoded_size as f64 / original_size as f64) * 100.0 - ); - - Ok((transcoded_bytes, target_format.mime_type().to_string(), true)) - } - - /// Perform actual image transcoding - fn transcode_image( - &self, - content: &[u8], - original_mime: &str, - target_format: OutputFormat, - ) -> Result, String> { - // Determine input format - let input_format = match original_mime { - "image/jpeg" | "image/jpg" => ImageFormat::Jpeg, - "image/png" => ImageFormat::Png, - "image/gif" => ImageFormat::Gif, - _ => return Err(format!("Unsupported input format: {}", original_mime)), - }; - - // Load image - let img = image::load_from_memory_with_format(content, input_format) - .map_err(|e| format!("Failed to decode image: {}", e))?; - - // Encode to target format - match target_format { - OutputFormat::WebP => self.encode_webp(&img), - } - } - - /// Encode image to WebP - fn encode_webp(&self, img: &DynamicImage) -> Result, String> { - let mut buffer = Vec::new(); - let mut cursor = std::io::Cursor::new(&mut buffer); - - // Use image crate's WebP encoder - img.write_to(&mut cursor, ImageFormat::WebP) - .map_err(|e| format!("Failed to encode WebP: {}", e))?; - - Ok(buffer) - } - - /// Get path for cached transcoded file - fn get_cache_path(&self, file_id: &str, format: OutputFormat) -> PathBuf { - self.cache_dir - .join(format.extension()) - .join(format!("{}.{}", file_id, format.extension())) - } - - /// Store transcoded image in memory cache - async fn cache_in_memory(&self, key: &TranscodeKey, content: Bytes) { - let size = content.len(); - - let mut current = self.current_memory_bytes.write().await; - - // Evict if needed - while *current + size > self.max_memory_bytes { - let mut cache = self.memory_cache.write().await; - if let Some((_, evicted)) = cache.pop_lru() { - *current = current.saturating_sub(evicted.len()); - } else { - break; - } - } - - // Add to cache - if *current + size <= self.max_memory_bytes { - let mut cache = self.memory_cache.write().await; - cache.put(key.clone(), content); - *current += size; - } - } - - /// Invalidate cached transcodes for a file - pub async fn invalidate(&self, file_id: &str) { - // Remove from memory cache - { - let mut cache = self.memory_cache.write().await; - let key = TranscodeKey { - file_id: file_id.to_string(), - format: OutputFormat::WebP, - }; - if let Some(removed) = cache.pop(&key) { - let mut current = self.current_memory_bytes.write().await; - *current = current.saturating_sub(removed.len()); - } - } - - // Remove disk cache - let cache_path = self.get_cache_path(file_id, OutputFormat::WebP); - let _ = fs::remove_file(&cache_path).await; - } - - /// Get transcoding statistics - pub async fn get_stats(&self) -> TranscodeStats { - self.stats.read().await.clone() - } - - /// Clear all caches - pub async fn clear_cache(&self) -> std::io::Result<()> { - // Clear memory - { - let mut cache = self.memory_cache.write().await; - cache.clear(); - let mut current = self.current_memory_bytes.write().await; - *current = 0; - } - - // Clear disk - if self.cache_dir.exists() { - fs::remove_dir_all(&self.cache_dir).await?; - fs::create_dir_all(&self.cache_dir).await?; - fs::create_dir_all(self.cache_dir.join("webp")).await?; - } - - Ok(()) - } -} - -// ─── Port implementation ───────────────────────────────────────────────────── - -/// Convert port OutputFormat to infra OutputFormat. -impl From for OutputFormat { - fn from(fmt: PortOutputFormat) -> Self { - match fmt { - PortOutputFormat::WebP => OutputFormat::WebP, - } - } -} - -#[async_trait] -impl ImageTranscodePort for ImageTranscodeService { - fn can_transcode(&self, mime_type: &str) -> bool { - ImageTranscodeService::can_transcode(mime_type) - } - - fn should_transcode(&self, mime_type: &str, file_size: u64) -> bool { - ImageTranscodeService::should_transcode(mime_type, file_size) - } - - async fn get_transcoded( - &self, - file_id: &str, - original_content: &[u8], - original_mime: &str, - target_format: PortOutputFormat, - ) -> Result<(Bytes, String, bool), DomainError> { - self.get_transcoded(file_id, original_content, original_mime, target_format.into()) - .await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ImageTranscode", e)) - } - - async fn invalidate(&self, file_id: &str) { - self.invalidate(file_id).await - } - - async fn get_stats(&self) -> TranscodeStatsDto { - let stats = self.get_stats().await; - TranscodeStatsDto { - cache_hits: stats.cache_hits, - disk_hits: stats.disk_hits, - transcodes: stats.transcodes, - bytes_saved: stats.bytes_saved, - transcode_errors: stats.transcode_errors, - } - } - - async fn clear_cache(&self) -> Result<(), DomainError> { - self.clear_cache().await.map_err(DomainError::from) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_browser_capabilities() { - // Chrome/Firefox with WebP support - let caps = BrowserCapabilities::from_accept_header( - Some("image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") - ); - assert!(caps.supports_webp); - assert!(caps.supports_avif); - - // Safari without WebP (old) - let caps = BrowserCapabilities::from_accept_header( - Some("image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5") - ); - assert!(!caps.supports_webp); - - // No header - let caps = BrowserCapabilities::from_accept_header(None); - assert!(!caps.supports_webp); - } - - #[test] - fn test_can_transcode() { - assert!(ImageTranscodeService::can_transcode("image/jpeg")); - assert!(ImageTranscodeService::can_transcode("image/png")); - assert!(ImageTranscodeService::can_transcode("image/gif")); - assert!(!ImageTranscodeService::can_transcode("image/webp")); - assert!(!ImageTranscodeService::can_transcode("image/svg+xml")); - assert!(!ImageTranscodeService::can_transcode("application/pdf")); - } - - #[test] - fn test_should_transcode() { - // Small JPEG - yes - assert!(ImageTranscodeService::should_transcode("image/jpeg", 1024 * 1024)); - - // Large JPEG - no (too big) - assert!(!ImageTranscodeService::should_transcode("image/jpeg", 10 * 1024 * 1024)); - - // WebP - no (already optimal) - assert!(!ImageTranscodeService::should_transcode("image/webp", 1024 * 1024)); - } -} +//! Image Transcoding Service - WebP On-Demand Conversion +//! +//! Automatically transcodes images to WebP format when the browser supports it, +//! reducing bandwidth by 30-50% compared to JPEG/PNG. +//! +//! Features: +//! - Detects browser WebP support via Accept header +//! - Caches transcoded versions to avoid re-conversion +//! - Supports JPEG, PNG, GIF → WebP conversion +//! - Configurable quality settings +//! - Falls back to original if conversion fails + +use async_trait::async_trait; +use bytes::Bytes; +use image::{DynamicImage, ImageFormat}; +use lru::LruCache; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs; +use tokio::sync::RwLock; + +use crate::application::ports::transcode_ports::{ + ImageTranscodePort, OutputFormat as PortOutputFormat, TranscodeStatsDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; + +/// Maximum file size for transcoding (5MB - larger files stream directly) +pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024; + +/// Cache key for transcoded images +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct TranscodeKey { + file_id: String, + format: OutputFormat, +} + +/// Supported output formats +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OutputFormat { + WebP, + // Future: AVIF, JPEG-XL +} + +impl OutputFormat { + pub fn extension(&self) -> &'static str { + match self { + OutputFormat::WebP => "webp", + } + } + + pub fn mime_type(&self) -> &'static str { + match self { + OutputFormat::WebP => "image/webp", + } + } +} + +/// Result of checking browser support +#[derive(Debug)] +pub struct BrowserCapabilities { + pub supports_webp: bool, + pub supports_avif: bool, +} + +impl BrowserCapabilities { + /// Parse Accept header to determine browser image format support + pub fn from_accept_header(accept: Option<&str>) -> Self { + let accept = accept.unwrap_or(""); + Self { + supports_webp: accept.contains("image/webp"), + supports_avif: accept.contains("image/avif"), + } + } + + /// Get the best output format for this browser + pub fn best_format(&self) -> Option { + // WebP has best support currently + if self.supports_webp { + Some(OutputFormat::WebP) + } else { + None + } + } +} + +/// Image Transcoding Service +pub struct ImageTranscodeService { + /// Cache directory for transcoded images + cache_dir: PathBuf, + /// In-memory LRU cache for hot transcoded images + memory_cache: Arc>>, + /// Maximum memory cache size in bytes + max_memory_bytes: usize, + /// Current memory usage + current_memory_bytes: Arc>, + /// Statistics + stats: Arc>, +} + +/// Transcoding statistics +#[derive(Debug, Default, Clone)] +pub struct TranscodeStats { + pub cache_hits: u64, + pub disk_hits: u64, + pub transcodes: u64, + pub bytes_saved: u64, + pub transcode_errors: u64, +} + +impl ImageTranscodeService { + /// Create new transcoding service + pub fn new(storage_root: &Path, max_cache_entries: usize, max_memory_bytes: usize) -> Self { + let cache_dir = storage_root.join(".transcoded"); + + Self { + cache_dir, + memory_cache: Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()), + ))), + max_memory_bytes, + current_memory_bytes: Arc::new(RwLock::new(0)), + stats: Arc::new(RwLock::new(TranscodeStats::default())), + } + } + + /// Initialize the service (create cache directories) + pub async fn initialize(&self) -> std::io::Result<()> { + fs::create_dir_all(&self.cache_dir).await?; + fs::create_dir_all(self.cache_dir.join("webp")).await?; + tracing::info!( + "🖼️ Image transcode service initialized at {:?}", + self.cache_dir + ); + Ok(()) + } + + /// Check if a mime type can be transcoded + pub fn can_transcode(mime_type: &str) -> bool { + matches!( + mime_type, + "image/jpeg" | "image/jpg" | "image/png" | "image/gif" + ) + } + + /// Check if transcoding should be attempted based on file size and type + pub fn should_transcode(mime_type: &str, file_size: u64) -> bool { + Self::can_transcode(mime_type) && file_size <= MAX_TRANSCODE_SIZE + } + + /// Get transcoded version of an image + /// Returns (content, mime_type, was_transcoded) + pub async fn get_transcoded( + &self, + file_id: &str, + original_content: &[u8], + original_mime: &str, + target_format: OutputFormat, + ) -> Result<(Bytes, String, bool), String> { + let key = TranscodeKey { + file_id: file_id.to_string(), + format: target_format, + }; + + // Check memory cache first + { + let mut cache = self.memory_cache.write().await; + if let Some(cached) = cache.get(&key) { + let mut stats = self.stats.write().await; + stats.cache_hits += 1; + tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id); + return Ok((cached.clone(), target_format.mime_type().to_string(), true)); + } + } + + // Check disk cache + let cache_path = self.get_cache_path(file_id, target_format); + if cache_path.exists() { + match fs::read(&cache_path).await { + Ok(data) => { + let content = Bytes::from(data); + + // Store in memory cache + self.cache_in_memory(&key, content.clone()).await; + + let mut stats = self.stats.write().await; + stats.disk_hits += 1; + tracing::debug!("💾 Transcode disk cache HIT: {}", file_id); + return Ok((content, target_format.mime_type().to_string(), true)); + } + Err(e) => { + tracing::warn!("Failed to read cached transcode: {}", e); + } + } + } + + // Need to transcode + let transcoded = self.transcode_image(original_content, original_mime, target_format)?; + let transcoded_bytes = Bytes::from(transcoded.clone()); + + // Calculate savings + let original_size = original_content.len(); + let transcoded_size = transcoded_bytes.len(); + let saved = original_size.saturating_sub(transcoded_size); + + // Only use transcoded if it's actually smaller + if transcoded_size >= original_size { + tracing::debug!( + "⚠️ Transcode not beneficial for {}: {} -> {} bytes", + file_id, + original_size, + transcoded_size + ); + return Ok(( + Bytes::from(original_content.to_vec()), + original_mime.to_string(), + false, + )); + } + + // Save to disk cache (async, don't wait) + let cache_path_clone = cache_path.clone(); + let transcoded_clone = transcoded.clone(); + tokio::spawn(async move { + if let Some(parent) = cache_path_clone.parent() { + let _ = fs::create_dir_all(parent).await; + } + if let Err(e) = fs::write(&cache_path_clone, &transcoded_clone).await { + tracing::warn!("Failed to cache transcoded image: {}", e); + } + }); + + // Store in memory cache + self.cache_in_memory(&key, transcoded_bytes.clone()).await; + + // Update stats + { + let mut stats = self.stats.write().await; + stats.transcodes += 1; + stats.bytes_saved += saved as u64; + } + + tracing::info!( + "✨ Transcoded {}: {} -> {} bytes ({:.1}% smaller)", + file_id, + original_size, + transcoded_size, + (1.0 - transcoded_size as f64 / original_size as f64) * 100.0 + ); + + Ok(( + transcoded_bytes, + target_format.mime_type().to_string(), + true, + )) + } + + /// Perform actual image transcoding + fn transcode_image( + &self, + content: &[u8], + original_mime: &str, + target_format: OutputFormat, + ) -> Result, String> { + // Determine input format + let input_format = match original_mime { + "image/jpeg" | "image/jpg" => ImageFormat::Jpeg, + "image/png" => ImageFormat::Png, + "image/gif" => ImageFormat::Gif, + _ => return Err(format!("Unsupported input format: {}", original_mime)), + }; + + // Load image + let img = image::load_from_memory_with_format(content, input_format) + .map_err(|e| format!("Failed to decode image: {}", e))?; + + // Encode to target format + match target_format { + OutputFormat::WebP => self.encode_webp(&img), + } + } + + /// Encode image to WebP + fn encode_webp(&self, img: &DynamicImage) -> Result, String> { + let mut buffer = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut buffer); + + // Use image crate's WebP encoder + img.write_to(&mut cursor, ImageFormat::WebP) + .map_err(|e| format!("Failed to encode WebP: {}", e))?; + + Ok(buffer) + } + + /// Get path for cached transcoded file + fn get_cache_path(&self, file_id: &str, format: OutputFormat) -> PathBuf { + self.cache_dir + .join(format.extension()) + .join(format!("{}.{}", file_id, format.extension())) + } + + /// Store transcoded image in memory cache + async fn cache_in_memory(&self, key: &TranscodeKey, content: Bytes) { + let size = content.len(); + + let mut current = self.current_memory_bytes.write().await; + + // Evict if needed + while *current + size > self.max_memory_bytes { + let mut cache = self.memory_cache.write().await; + if let Some((_, evicted)) = cache.pop_lru() { + *current = current.saturating_sub(evicted.len()); + } else { + break; + } + } + + // Add to cache + if *current + size <= self.max_memory_bytes { + let mut cache = self.memory_cache.write().await; + cache.put(key.clone(), content); + *current += size; + } + } + + /// Invalidate cached transcodes for a file + pub async fn invalidate(&self, file_id: &str) { + // Remove from memory cache + { + let mut cache = self.memory_cache.write().await; + let key = TranscodeKey { + file_id: file_id.to_string(), + format: OutputFormat::WebP, + }; + if let Some(removed) = cache.pop(&key) { + let mut current = self.current_memory_bytes.write().await; + *current = current.saturating_sub(removed.len()); + } + } + + // Remove disk cache + let cache_path = self.get_cache_path(file_id, OutputFormat::WebP); + let _ = fs::remove_file(&cache_path).await; + } + + /// Get transcoding statistics + pub async fn get_stats(&self) -> TranscodeStats { + self.stats.read().await.clone() + } + + /// Clear all caches + pub async fn clear_cache(&self) -> std::io::Result<()> { + // Clear memory + { + let mut cache = self.memory_cache.write().await; + cache.clear(); + let mut current = self.current_memory_bytes.write().await; + *current = 0; + } + + // Clear disk + if self.cache_dir.exists() { + fs::remove_dir_all(&self.cache_dir).await?; + fs::create_dir_all(&self.cache_dir).await?; + fs::create_dir_all(self.cache_dir.join("webp")).await?; + } + + Ok(()) + } +} + +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert port OutputFormat to infra OutputFormat. +impl From for OutputFormat { + fn from(fmt: PortOutputFormat) -> Self { + match fmt { + PortOutputFormat::WebP => OutputFormat::WebP, + } + } +} + +#[async_trait] +impl ImageTranscodePort for ImageTranscodeService { + fn can_transcode(&self, mime_type: &str) -> bool { + ImageTranscodeService::can_transcode(mime_type) + } + + fn should_transcode(&self, mime_type: &str, file_size: u64) -> bool { + ImageTranscodeService::should_transcode(mime_type, file_size) + } + + async fn get_transcoded( + &self, + file_id: &str, + original_content: &[u8], + original_mime: &str, + target_format: PortOutputFormat, + ) -> Result<(Bytes, String, bool), DomainError> { + self.get_transcoded( + file_id, + original_content, + original_mime, + target_format.into(), + ) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ImageTranscode", e)) + } + + async fn invalidate(&self, file_id: &str) { + self.invalidate(file_id).await + } + + async fn get_stats(&self) -> TranscodeStatsDto { + let stats = self.get_stats().await; + TranscodeStatsDto { + cache_hits: stats.cache_hits, + disk_hits: stats.disk_hits, + transcodes: stats.transcodes, + bytes_saved: stats.bytes_saved, + transcode_errors: stats.transcode_errors, + } + } + + async fn clear_cache(&self) -> Result<(), DomainError> { + self.clear_cache().await.map_err(DomainError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_browser_capabilities() { + // Chrome/Firefox with WebP support + let caps = BrowserCapabilities::from_accept_header(Some( + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + )); + assert!(caps.supports_webp); + assert!(caps.supports_avif); + + // Safari without WebP (old) + let caps = BrowserCapabilities::from_accept_header(Some( + "image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5", + )); + assert!(!caps.supports_webp); + + // No header + let caps = BrowserCapabilities::from_accept_header(None); + assert!(!caps.supports_webp); + } + + #[test] + fn test_can_transcode() { + assert!(ImageTranscodeService::can_transcode("image/jpeg")); + assert!(ImageTranscodeService::can_transcode("image/png")); + assert!(ImageTranscodeService::can_transcode("image/gif")); + assert!(!ImageTranscodeService::can_transcode("image/webp")); + assert!(!ImageTranscodeService::can_transcode("image/svg+xml")); + assert!(!ImageTranscodeService::can_transcode("application/pdf")); + } + + #[test] + fn test_should_transcode() { + // Small JPEG - yes + assert!(ImageTranscodeService::should_transcode( + "image/jpeg", + 1024 * 1024 + )); + + // Large JPEG - no (too big) + assert!(!ImageTranscodeService::should_transcode( + "image/jpeg", + 10 * 1024 * 1024 + )); + + // WebP - no (already optimal) + assert!(!ImageTranscodeService::should_transcode( + "image/webp", + 1024 * 1024 + )); + } +} diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index 7634947b..b717fe72 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -1,210 +1,221 @@ -//! JWT-based token service implementation. -//! -//! This module provides JWT token generation and validation functionality, -//! implementing the TokenServicePort trait defined in the application layer. - -use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, Algorithm}; -use serde::{Serialize, Deserialize}; -use uuid::Uuid; -use chrono::Utc; - -use crate::application::ports::auth_ports::{TokenServicePort, TokenClaims}; -use crate::domain::entities::user::User; -use crate::common::errors::{DomainError, ErrorKind}; - -/// Internal JWT claims structure for serialization. -/// This is the actual JWT payload structure used by jsonwebtoken crate. -#[derive(Debug, Serialize, Deserialize)] -struct JwtClaims { - /// Subject identifier - contains the user ID - pub sub: String, - /// Expiration timestamp (seconds since Unix epoch) - pub exp: i64, - /// Issued at timestamp (seconds since Unix epoch) - pub iat: i64, - /// JWT unique ID for token tracking and revocation - pub jti: String, - /// Username for display and identification purposes - pub username: String, - /// User email for communication and identification - pub email: String, - /// User role for authorization checks - pub role: String, -} - -impl From for TokenClaims { - fn from(claims: JwtClaims) -> Self { - TokenClaims { - sub: claims.sub, - exp: claims.exp, - iat: claims.iat, - jti: claims.jti, - username: claims.username, - email: claims.email, - role: claims.role, - } - } -} - -/// JWT-based implementation of the TokenServicePort. -/// -/// This service handles JWT token generation and validation for user authentication. -/// It uses HS256 algorithm for signing tokens. -pub struct JwtTokenService { - /// Secret key used for signing JWT tokens - jwt_secret: String, - /// Expiration time for access tokens in seconds - access_token_expiry: i64, - /// Expiration time for refresh tokens in seconds - refresh_token_expiry: i64, -} - -impl JwtTokenService { - /// Create a new JwtTokenService with the specified configuration. - /// - /// # Arguments - /// * `jwt_secret` - Secret key for signing tokens (should be at least 32 bytes) - /// * `access_token_expiry_secs` - Lifetime of access tokens in seconds - /// * `refresh_token_expiry_secs` - Lifetime of refresh tokens in seconds - pub fn new(jwt_secret: String, access_token_expiry_secs: i64, refresh_token_expiry_secs: i64) -> Self { - Self { - jwt_secret, - access_token_expiry: access_token_expiry_secs, - refresh_token_expiry: refresh_token_expiry_secs, - } - } -} - -impl TokenServicePort for JwtTokenService { - fn generate_access_token(&self, user: &User) -> Result { - let now = Utc::now().timestamp(); - - // Log information for debugging - tracing::debug!( - "Generating token for user: {}, id: {}, role: {}", - user.username(), - user.id(), - user.role() - ); - - let claims = JwtClaims { - sub: user.id().to_string(), - exp: now + self.access_token_expiry, - iat: now, - jti: Uuid::new_v4().to_string(), - username: user.username().to_string(), - email: user.email().to_string(), - role: format!("{}", user.role()), - }; - - // Log JWT claims for debugging - tracing::debug!("JWT claims: sub={}, exp={}, iat={}", claims.sub, claims.exp, claims.iat); - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(self.jwt_secret.as_bytes()) - ) - .map_err(|e| { - tracing::error!("Error generating token: {}", e); - DomainError::new( - ErrorKind::InternalError, - "TokenService", - format!("Error generating token: {}", e) - ) - }) - } - - fn validate_token(&self, token: &str) -> Result { - let validation = Validation::new(Algorithm::HS256); - - let token_data = decode::( - token, - &DecodingKey::from_secret(self.jwt_secret.as_bytes()), - &validation - ) - .map_err(|e| { - match e.kind() { - jsonwebtoken::errors::ErrorKind::ExpiredSignature => { - DomainError::new(ErrorKind::AccessDenied, "TokenService", "Token expired") - }, - _ => DomainError::new( - ErrorKind::AccessDenied, - "TokenService", - format!("Invalid token: {}", e) - ), - } - })?; - - Ok(token_data.claims.into()) - } - - fn generate_refresh_token(&self) -> String { - Uuid::new_v4().to_string() - } - - fn refresh_token_expiry_secs(&self) -> i64 { - self.refresh_token_expiry - } - - fn refresh_token_expiry_days(&self) -> i64 { - self.refresh_token_expiry / (24 * 3600) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::entities::user::{User, UserRole}; - - fn create_test_user() -> User { - User::from_data( - "test-user-id".to_string(), - "testuser".to_string(), - "test@example.com".to_string(), - "hashed_password".to_string(), - UserRole::User, - 1024 * 1024 * 1024, // 1GB - 0, - chrono::Utc::now(), - chrono::Utc::now(), - None, - true, - ) - } - - #[test] - fn test_generate_and_validate_token() { - let service = JwtTokenService::new( - "test_secret_key_at_least_32_bytes_long".to_string(), - 3600, // 1 hour - 86400, // 1 day - ); - - let user = create_test_user(); - let token = service.generate_access_token(&user).expect("Should generate token"); - - let claims = service.validate_token(&token).expect("Should validate token"); - assert_eq!(claims.sub, user.id()); - assert_eq!(claims.username, user.username()); - assert_eq!(claims.email, user.email()); - } - - #[test] - fn test_refresh_token_is_unique() { - let service = JwtTokenService::new("secret".to_string(), 3600, 86400); - - let token1 = service.generate_refresh_token(); - let token2 = service.generate_refresh_token(); - - assert_ne!(token1, token2); - } - - #[test] - fn test_invalid_token() { - let service = JwtTokenService::new("secret".to_string(), 3600, 86400); - - let result = service.validate_token("invalid_token"); - assert!(result.is_err()); - } -} +//! JWT-based token service implementation. +//! +//! This module provides JWT token generation and validation functionality, +//! implementing the TokenServicePort trait defined in the application layer. + +use chrono::Utc; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::application::ports::auth_ports::{TokenClaims, TokenServicePort}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::user::User; + +/// Internal JWT claims structure for serialization. +/// This is the actual JWT payload structure used by jsonwebtoken crate. +#[derive(Debug, Serialize, Deserialize)] +struct JwtClaims { + /// Subject identifier - contains the user ID + pub sub: String, + /// Expiration timestamp (seconds since Unix epoch) + pub exp: i64, + /// Issued at timestamp (seconds since Unix epoch) + pub iat: i64, + /// JWT unique ID for token tracking and revocation + pub jti: String, + /// Username for display and identification purposes + pub username: String, + /// User email for communication and identification + pub email: String, + /// User role for authorization checks + pub role: String, +} + +impl From for TokenClaims { + fn from(claims: JwtClaims) -> Self { + TokenClaims { + sub: claims.sub, + exp: claims.exp, + iat: claims.iat, + jti: claims.jti, + username: claims.username, + email: claims.email, + role: claims.role, + } + } +} + +/// JWT-based implementation of the TokenServicePort. +/// +/// This service handles JWT token generation and validation for user authentication. +/// It uses HS256 algorithm for signing tokens. +pub struct JwtTokenService { + /// Secret key used for signing JWT tokens + jwt_secret: String, + /// Expiration time for access tokens in seconds + access_token_expiry: i64, + /// Expiration time for refresh tokens in seconds + refresh_token_expiry: i64, +} + +impl JwtTokenService { + /// Create a new JwtTokenService with the specified configuration. + /// + /// # Arguments + /// * `jwt_secret` - Secret key for signing tokens (should be at least 32 bytes) + /// * `access_token_expiry_secs` - Lifetime of access tokens in seconds + /// * `refresh_token_expiry_secs` - Lifetime of refresh tokens in seconds + pub fn new( + jwt_secret: String, + access_token_expiry_secs: i64, + refresh_token_expiry_secs: i64, + ) -> Self { + Self { + jwt_secret, + access_token_expiry: access_token_expiry_secs, + refresh_token_expiry: refresh_token_expiry_secs, + } + } +} + +impl TokenServicePort for JwtTokenService { + fn generate_access_token(&self, user: &User) -> Result { + let now = Utc::now().timestamp(); + + // Log information for debugging + tracing::debug!( + "Generating token for user: {}, id: {}, role: {}", + user.username(), + user.id(), + user.role() + ); + + let claims = JwtClaims { + sub: user.id().to_string(), + exp: now + self.access_token_expiry, + iat: now, + jti: Uuid::new_v4().to_string(), + username: user.username().to_string(), + email: user.email().to_string(), + role: format!("{}", user.role()), + }; + + // Log JWT claims for debugging + tracing::debug!( + "JWT claims: sub={}, exp={}, iat={}", + claims.sub, + claims.exp, + claims.iat + ); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_bytes()), + ) + .map_err(|e| { + tracing::error!("Error generating token: {}", e); + DomainError::new( + ErrorKind::InternalError, + "TokenService", + format!("Error generating token: {}", e), + ) + }) + } + + fn validate_token(&self, token: &str) -> Result { + let validation = Validation::new(Algorithm::HS256); + + let token_data = decode::( + token, + &DecodingKey::from_secret(self.jwt_secret.as_bytes()), + &validation, + ) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => { + DomainError::new(ErrorKind::AccessDenied, "TokenService", "Token expired") + } + _ => DomainError::new( + ErrorKind::AccessDenied, + "TokenService", + format!("Invalid token: {}", e), + ), + })?; + + Ok(token_data.claims.into()) + } + + fn generate_refresh_token(&self) -> String { + Uuid::new_v4().to_string() + } + + fn refresh_token_expiry_secs(&self) -> i64 { + self.refresh_token_expiry + } + + fn refresh_token_expiry_days(&self) -> i64 { + self.refresh_token_expiry / (24 * 3600) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::entities::user::{User, UserRole}; + + fn create_test_user() -> User { + User::from_data( + "test-user-id".to_string(), + "testuser".to_string(), + "test@example.com".to_string(), + "hashed_password".to_string(), + UserRole::User, + 1024 * 1024 * 1024, // 1GB + 0, + chrono::Utc::now(), + chrono::Utc::now(), + None, + true, + ) + } + + #[test] + fn test_generate_and_validate_token() { + let service = JwtTokenService::new( + "test_secret_key_at_least_32_bytes_long".to_string(), + 3600, // 1 hour + 86400, // 1 day + ); + + let user = create_test_user(); + let token = service + .generate_access_token(&user) + .expect("Should generate token"); + + let claims = service + .validate_token(&token) + .expect("Should validate token"); + assert_eq!(claims.sub, user.id()); + assert_eq!(claims.username, user.username()); + assert_eq!(claims.email, user.email()); + } + + #[test] + fn test_refresh_token_is_unique() { + let service = JwtTokenService::new("secret".to_string(), 3600, 86400); + + let token1 = service.generate_refresh_token(); + let token2 = service.generate_refresh_token(); + + assert_ne!(token1, token2); + } + + #[test] + fn test_invalid_token() { + let service = JwtTokenService::new("secret".to_string(), 3600, 86400); + + let result = service.validate_token("invalid_token"); + assert!(result.is_err()); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index affb8d02..62bc703c 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -1,19 +1,19 @@ +pub mod buffer_pool; +pub mod chunked_upload_service; +pub mod compression_service; +pub mod dedup_service; +pub mod file_content_cache; +pub mod file_metadata_cache; pub mod file_system_i18n_service; pub mod file_system_utils; -pub mod id_mapping_service; pub mod id_mapping_optimizer; -pub mod file_metadata_cache; -pub mod file_content_cache; -pub mod compression_service; -pub mod buffer_pool; -pub mod trash_cleanup_service; -pub mod zip_service; -pub mod path_service; -pub mod password_hasher; -pub mod jwt_service; -pub mod thumbnail_service; -pub mod write_behind_cache; -pub mod chunked_upload_service; +pub mod id_mapping_service; pub mod image_transcode_service; -pub mod dedup_service; -pub mod oidc_service; \ No newline at end of file +pub mod jwt_service; +pub mod oidc_service; +pub mod password_hasher; +pub mod path_service; +pub mod thumbnail_service; +pub mod trash_cleanup_service; +pub mod write_behind_cache; +pub mod zip_service; diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 4f645168..d5c65cf2 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -1,449 +1,531 @@ -//! OpenID Connect (OIDC) service implementation. -//! -//! Handles OIDC discovery, authorization URL generation, code exchange, -//! ID token validation (RS256 via JWKS), and UserInfo fetching. -//! Compatible with Authentik, Keycloak, and any standard OIDC provider. - -use std::sync::RwLock; -use async_trait::async_trait; -use serde::Deserialize; - -use crate::application::ports::auth_ports::{OidcServicePort, OidcTokenSet, OidcIdClaims}; -use crate::common::config::OidcConfig; -use crate::common::errors::{DomainError, ErrorKind}; - -// ============================================================================ -// OIDC Discovery Document -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -struct OidcDiscovery { - issuer: String, - authorization_endpoint: String, - token_endpoint: String, - userinfo_endpoint: Option, - jwks_uri: String, -} - -// ============================================================================ -// JWKS structures for RS256 validation -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -struct JwksDocument { - keys: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -struct JwkKey { - kty: String, - #[serde(rename = "use")] - key_use: Option, - kid: Option, - alg: Option, - n: Option, // RSA modulus (base64url) - e: Option, // RSA exponent (base64url) -} - -// ============================================================================ -// Token exchange response -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: String, - id_token: Option, - refresh_token: Option, - #[allow(dead_code)] - token_type: Option, - #[allow(dead_code)] - expires_in: Option, -} - -// ============================================================================ -// ID token claims (standard OIDC) -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct IdTokenClaims { - sub: String, - email: Option, - preferred_username: Option, - name: Option, - groups: Option>, - nonce: Option, - // Standard JWT fields - #[allow(dead_code)] - iss: Option, - #[allow(dead_code)] - aud: Option, - #[allow(dead_code)] - exp: Option, - #[allow(dead_code)] - iat: Option, -} - -// ============================================================================ -// UserInfo response -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct UserInfoResponse { - sub: String, - email: Option, - preferred_username: Option, - name: Option, - groups: Option>, -} - -// ============================================================================ -// OIDC Service -// ============================================================================ - -pub struct OidcService { - config: OidcConfig, - http_client: reqwest::Client, - /// Cached discovery document - discovery: RwLock>, - /// Cached JWKS - jwks: RwLock>, -} - -impl OidcService { - pub fn new(config: OidcConfig) -> Self { - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .expect("Failed to build HTTP client for OIDC"); - - Self { - config, - http_client, - discovery: RwLock::new(None), - jwks: RwLock::new(None), - } - } - - /// Fetch and cache the OIDC discovery document - async fn get_discovery(&self) -> Result { - // Check cache first - { - let cache = self.discovery.read().map_err(|_| DomainError::new( - ErrorKind::InternalError, "OIDC", "Lock poisoned", - ))?; - if let Some(ref disc) = *cache { - return Ok(disc.clone()); - } - } - - // Fetch discovery document - let issuer = self.config.issuer_url.trim_end_matches('/'); - let discovery_url = format!("{}/.well-known/openid-configuration", issuer); - - tracing::info!("Fetching OIDC discovery from: {}", discovery_url); - - let resp = self.http_client.get(&discovery_url) - .send() - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to fetch OIDC discovery: {}", e), - ))?; - - if !resp.status().is_success() { - return Err(DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("OIDC discovery returned status {}", resp.status()), - )); - } - - let discovery: OidcDiscovery = resp.json().await.map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to parse OIDC discovery: {}", e), - ))?; - - // Cache it - { - let mut cache = self.discovery.write().map_err(|_| DomainError::new( - ErrorKind::InternalError, "OIDC", "Lock poisoned", - ))?; - *cache = Some(discovery.clone()); - } - - Ok(discovery) - } - - /// Fetch and cache JWKS document for ID token validation - async fn get_jwks(&self) -> Result { - // Check cache first - { - let cache = self.jwks.read().map_err(|_| DomainError::new( - ErrorKind::InternalError, "OIDC", "Lock poisoned", - ))?; - if let Some(ref jwks) = *cache { - return Ok(jwks.clone()); - } - } - - let discovery = self.get_discovery().await?; - - tracing::debug!("Fetching JWKS from: {}", discovery.jwks_uri); - - let resp = self.http_client.get(&discovery.jwks_uri) - .send() - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to fetch JWKS: {}", e), - ))?; - - let jwks: JwksDocument = resp.json().await.map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to parse JWKS: {}", e), - ))?; - - // Cache it - { - let mut cache = self.jwks.write().map_err(|_| DomainError::new( - ErrorKind::InternalError, "OIDC", "Lock poisoned", - ))?; - *cache = Some(jwks.clone()); - } - - Ok(jwks) - } - - /// Find the right RSA key from JWKS by kid header - fn find_rsa_key<'a>(jwks: &'a JwksDocument, kid: Option<&str>) -> Option<&'a JwkKey> { - jwks.keys.iter().find(|k| { - k.kty == "RSA" - && k.key_use.as_deref() != Some("enc") // exclude encryption keys - && (kid.is_none() || k.kid.as_deref() == kid) - }) - } - - /// Extract the `kid` from a JWT header without full validation - fn extract_jwt_kid(token: &str) -> Option { - let parts: Vec<&str> = token.splitn(3, '.').collect(); - if parts.len() < 2 { - return None; - } - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header_bytes = engine.decode(parts[0]).ok()?; - let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; - header.get("kid").and_then(|v| v.as_str()).map(|s| s.to_string()) - } -} - -#[async_trait] -impl OidcServicePort for OidcService { - async fn get_authorize_url(&self, state: &str, nonce: &str, pkce_challenge: &str) -> Result { - // Fetch or use cached discovery to get the correct authorization_endpoint - let discovery = self.get_discovery().await?; - let auth_endpoint = discovery.authorization_endpoint; - - let scopes = self.config.scopes.replace(',', " "); - let url = format!( - "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256", - auth_endpoint, - urlencoding::encode(&self.config.client_id), - urlencoding::encode(&self.config.redirect_uri), - urlencoding::encode(&scopes), - urlencoding::encode(state), - urlencoding::encode(nonce), - urlencoding::encode(pkce_challenge), - ); - - Ok(url) - } - - async fn exchange_code(&self, code: &str, pkce_verifier: &str) -> Result { - let discovery = self.get_discovery().await?; - - tracing::debug!("Exchanging authorization code at: {}", discovery.token_endpoint); - - let resp = self.http_client.post(&discovery.token_endpoint) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", &self.config.redirect_uri), - ("client_id", &self.config.client_id), - ("client_secret", &self.config.client_secret), - ("code_verifier", pkce_verifier), - ]) - .send() - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Token exchange failed: {}", e), - ))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - tracing::error!("OIDC token exchange error: status={}, body={}", status, body); - return Err(DomainError::new( - ErrorKind::AccessDenied, "OIDC", - format!("Token exchange failed with status {}", status), - )); - } - - let token_resp: TokenResponse = resp.json().await.map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to parse token response: {}", e), - ))?; - - let id_token = token_resp.id_token.ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", - "No id_token in token response", - ))?; - - Ok(OidcTokenSet { - access_token: token_resp.access_token, - id_token, - refresh_token: token_resp.refresh_token, - }) - } - - async fn validate_id_token(&self, id_token: &str, expected_nonce: Option<&str>) -> Result { - let jwks = self.get_jwks().await?; - let discovery = self.get_discovery().await?; - - // Extract kid from JWT header - let kid = Self::extract_jwt_kid(id_token); - - // Find the matching RSA key - let jwk = Self::find_rsa_key(&jwks, kid.as_deref()).ok_or_else(|| DomainError::new( - ErrorKind::AccessDenied, "OIDC", - "No suitable RSA key found in JWKS for ID token validation", - ))?; - - let n = jwk.n.as_ref().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "JWKS key missing 'n' component", - ))?; - let e = jwk.e.as_ref().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "JWKS key missing 'e' component", - ))?; - - // Build decoding key from RSA components - let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e) - .map_err(|err| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to build RSA decoding key: {}", err), - ))?; - - // Determine algorithm from JWKS (default RS256) - let alg = match jwk.alg.as_deref() { - Some("RS384") => jsonwebtoken::Algorithm::RS384, - Some("RS512") => jsonwebtoken::Algorithm::RS512, - _ => jsonwebtoken::Algorithm::RS256, - }; - - // Build validation: check expiry and issuer - let mut validation = jsonwebtoken::Validation::new(alg); - validation.set_issuer(&[&discovery.issuer]); - validation.set_audience(&[&self.config.client_id]); - - let token_data = jsonwebtoken::decode::( - id_token, - &decoding_key, - &validation, - ).map_err(|e| { - tracing::warn!("OIDC ID token validation failed: {}", e); - DomainError::new( - ErrorKind::AccessDenied, "OIDC", - format!("ID token validation failed: {}", e), - ) - })?; - - let claims = token_data.claims; - - // Verify nonce to prevent token replay attacks - if let Some(expected) = expected_nonce { - match &claims.nonce { - Some(actual) if actual == expected => { /* OK */ } - Some(actual) => { - tracing::warn!("OIDC nonce mismatch: expected={}, got={}", expected, actual); - return Err(DomainError::new( - ErrorKind::AccessDenied, "OIDC", - "ID token nonce mismatch — possible replay attack", - )); - } - None => { - tracing::warn!("OIDC nonce missing from ID token (expected={})", expected); - // Some providers don't include nonce; log warning but don't fail - } - } - } - - Ok(OidcIdClaims { - sub: claims.sub, - email: claims.email, - preferred_username: claims.preferred_username, - name: claims.name, - groups: claims.groups.unwrap_or_default(), - }) - } - - async fn fetch_user_info(&self, access_token: &str) -> Result { - let discovery = self.get_discovery().await?; - - let userinfo_url = discovery.userinfo_endpoint.ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", - "No userinfo_endpoint in OIDC discovery", - ))?; - - let resp = self.http_client.get(&userinfo_url) - .header("Authorization", format!("Bearer {}", access_token)) - .send() - .await - .map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("UserInfo request failed: {}", e), - ))?; - - if !resp.status().is_success() { - return Err(DomainError::new( - ErrorKind::AccessDenied, "OIDC", - format!("UserInfo returned status {}", resp.status()), - )); - } - - let info: UserInfoResponse = resp.json().await.map_err(|e| DomainError::new( - ErrorKind::InternalError, "OIDC", - format!("Failed to parse UserInfo: {}", e), - ))?; - - Ok(OidcIdClaims { - sub: info.sub, - email: info.email, - preferred_username: info.preferred_username, - name: info.name, - groups: info.groups.unwrap_or_default(), - }) - } - - fn provider_name(&self) -> &str { - &self.config.provider_name - } -} - -// We need urlencoding — let's use a minimal inline implementation -mod urlencoding { - pub fn encode(input: &str) -> String { - let mut result = String::with_capacity(input.len() * 3); - for byte in input.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - result.push(byte as char); - } - _ => { - result.push('%'); - result.push_str(&format!("{:02X}", byte)); - } - } - } - result - } -} +//! OpenID Connect (OIDC) service implementation. +//! +//! Handles OIDC discovery, authorization URL generation, code exchange, +//! ID token validation (RS256 via JWKS), and UserInfo fetching. +//! Compatible with Authentik, Keycloak, and any standard OIDC provider. + +use async_trait::async_trait; +use serde::Deserialize; +use std::sync::RwLock; + +use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet}; +use crate::common::config::OidcConfig; +use crate::common::errors::{DomainError, ErrorKind}; + +// ============================================================================ +// OIDC Discovery Document +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +struct OidcDiscovery { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + userinfo_endpoint: Option, + jwks_uri: String, +} + +// ============================================================================ +// JWKS structures for RS256 validation +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +struct JwksDocument { + keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct JwkKey { + kty: String, + #[serde(rename = "use")] + key_use: Option, + kid: Option, + alg: Option, + n: Option, // RSA modulus (base64url) + e: Option, // RSA exponent (base64url) +} + +// ============================================================================ +// Token exchange response +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + id_token: Option, + refresh_token: Option, + #[allow(dead_code)] + token_type: Option, + #[allow(dead_code)] + expires_in: Option, +} + +// ============================================================================ +// ID token claims (standard OIDC) +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct IdTokenClaims { + sub: String, + email: Option, + preferred_username: Option, + name: Option, + groups: Option>, + nonce: Option, + // Standard JWT fields + #[allow(dead_code)] + iss: Option, + #[allow(dead_code)] + aud: Option, + #[allow(dead_code)] + exp: Option, + #[allow(dead_code)] + iat: Option, +} + +// ============================================================================ +// UserInfo response +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct UserInfoResponse { + sub: String, + email: Option, + preferred_username: Option, + name: Option, + groups: Option>, +} + +// ============================================================================ +// OIDC Service +// ============================================================================ + +pub struct OidcService { + config: OidcConfig, + http_client: reqwest::Client, + /// Cached discovery document + discovery: RwLock>, + /// Cached JWKS + jwks: RwLock>, +} + +impl OidcService { + pub fn new(config: OidcConfig) -> Self { + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client for OIDC"); + + Self { + config, + http_client, + discovery: RwLock::new(None), + jwks: RwLock::new(None), + } + } + + /// Fetch and cache the OIDC discovery document + async fn get_discovery(&self) -> Result { + // Check cache first + { + let cache = self + .discovery + .read() + .map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?; + if let Some(ref disc) = *cache { + return Ok(disc.clone()); + } + } + + // Fetch discovery document + let issuer = self.config.issuer_url.trim_end_matches('/'); + let discovery_url = format!("{}/.well-known/openid-configuration", issuer); + + tracing::info!("Fetching OIDC discovery from: {}", discovery_url); + + let resp = self + .http_client + .get(&discovery_url) + .send() + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to fetch OIDC discovery: {}", e), + ) + })?; + + if !resp.status().is_success() { + return Err(DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("OIDC discovery returned status {}", resp.status()), + )); + } + + let discovery: OidcDiscovery = resp.json().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to parse OIDC discovery: {}", e), + ) + })?; + + // Cache it + { + let mut cache = self + .discovery + .write() + .map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?; + *cache = Some(discovery.clone()); + } + + Ok(discovery) + } + + /// Fetch and cache JWKS document for ID token validation + async fn get_jwks(&self) -> Result { + // Check cache first + { + let cache = self + .jwks + .read() + .map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?; + if let Some(ref jwks) = *cache { + return Ok(jwks.clone()); + } + } + + let discovery = self.get_discovery().await?; + + tracing::debug!("Fetching JWKS from: {}", discovery.jwks_uri); + + let resp = self + .http_client + .get(&discovery.jwks_uri) + .send() + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to fetch JWKS: {}", e), + ) + })?; + + let jwks: JwksDocument = resp.json().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to parse JWKS: {}", e), + ) + })?; + + // Cache it + { + let mut cache = self + .jwks + .write() + .map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?; + *cache = Some(jwks.clone()); + } + + Ok(jwks) + } + + /// Find the right RSA key from JWKS by kid header + fn find_rsa_key<'a>(jwks: &'a JwksDocument, kid: Option<&str>) -> Option<&'a JwkKey> { + jwks.keys.iter().find(|k| { + k.kty == "RSA" + && k.key_use.as_deref() != Some("enc") // exclude encryption keys + && (kid.is_none() || k.kid.as_deref() == kid) + }) + } + + /// Extract the `kid` from a JWT header without full validation + fn extract_jwt_kid(token: &str) -> Option { + let parts: Vec<&str> = token.splitn(3, '.').collect(); + if parts.len() < 2 { + return None; + } + use base64::Engine; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header_bytes = engine.decode(parts[0]).ok()?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; + header + .get("kid") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } +} + +#[async_trait] +impl OidcServicePort for OidcService { + async fn get_authorize_url( + &self, + state: &str, + nonce: &str, + pkce_challenge: &str, + ) -> Result { + // Fetch or use cached discovery to get the correct authorization_endpoint + let discovery = self.get_discovery().await?; + let auth_endpoint = discovery.authorization_endpoint; + + let scopes = self.config.scopes.replace(',', " "); + let url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256", + auth_endpoint, + urlencoding::encode(&self.config.client_id), + urlencoding::encode(&self.config.redirect_uri), + urlencoding::encode(&scopes), + urlencoding::encode(state), + urlencoding::encode(nonce), + urlencoding::encode(pkce_challenge), + ); + + Ok(url) + } + + async fn exchange_code( + &self, + code: &str, + pkce_verifier: &str, + ) -> Result { + let discovery = self.get_discovery().await?; + + tracing::debug!( + "Exchanging authorization code at: {}", + discovery.token_endpoint + ); + + let resp = self + .http_client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &self.config.redirect_uri), + ("client_id", &self.config.client_id), + ("client_secret", &self.config.client_secret), + ("code_verifier", pkce_verifier), + ]) + .send() + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Token exchange failed: {}", e), + ) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::error!( + "OIDC token exchange error: status={}, body={}", + status, + body + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + format!("Token exchange failed with status {}", status), + )); + } + + let token_resp: TokenResponse = resp.json().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to parse token response: {}", e), + ) + })?; + + let id_token = token_resp.id_token.ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "No id_token in token response", + ) + })?; + + Ok(OidcTokenSet { + access_token: token_resp.access_token, + id_token, + refresh_token: token_resp.refresh_token, + }) + } + + async fn validate_id_token( + &self, + id_token: &str, + expected_nonce: Option<&str>, + ) -> Result { + let jwks = self.get_jwks().await?; + let discovery = self.get_discovery().await?; + + // Extract kid from JWT header + let kid = Self::extract_jwt_kid(id_token); + + // Find the matching RSA key + let jwk = Self::find_rsa_key(&jwks, kid.as_deref()).ok_or_else(|| { + DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "No suitable RSA key found in JWKS for ID token validation", + ) + })?; + + let n = jwk.n.as_ref().ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "JWKS key missing 'n' component", + ) + })?; + let e = jwk.e.as_ref().ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "JWKS key missing 'e' component", + ) + })?; + + // Build decoding key from RSA components + let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e).map_err(|err| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to build RSA decoding key: {}", err), + ) + })?; + + // Determine algorithm from JWKS (default RS256) + let alg = match jwk.alg.as_deref() { + Some("RS384") => jsonwebtoken::Algorithm::RS384, + Some("RS512") => jsonwebtoken::Algorithm::RS512, + _ => jsonwebtoken::Algorithm::RS256, + }; + + // Build validation: check expiry and issuer + let mut validation = jsonwebtoken::Validation::new(alg); + validation.set_issuer(&[&discovery.issuer]); + validation.set_audience(&[&self.config.client_id]); + + let token_data = + jsonwebtoken::decode::(id_token, &decoding_key, &validation).map_err( + |e| { + tracing::warn!("OIDC ID token validation failed: {}", e); + DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + format!("ID token validation failed: {}", e), + ) + }, + )?; + + let claims = token_data.claims; + + // Verify nonce to prevent token replay attacks + if let Some(expected) = expected_nonce { + match &claims.nonce { + Some(actual) if actual == expected => { /* OK */ } + Some(actual) => { + tracing::warn!("OIDC nonce mismatch: expected={}, got={}", expected, actual); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "ID token nonce mismatch — possible replay attack", + )); + } + None => { + tracing::warn!("OIDC nonce missing from ID token (expected={})", expected); + // Some providers don't include nonce; log warning but don't fail + } + } + } + + Ok(OidcIdClaims { + sub: claims.sub, + email: claims.email, + preferred_username: claims.preferred_username, + name: claims.name, + groups: claims.groups.unwrap_or_default(), + }) + } + + async fn fetch_user_info(&self, access_token: &str) -> Result { + let discovery = self.get_discovery().await?; + + let userinfo_url = discovery.userinfo_endpoint.ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "No userinfo_endpoint in OIDC discovery", + ) + })?; + + let resp = self + .http_client + .get(&userinfo_url) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("UserInfo request failed: {}", e), + ) + })?; + + if !resp.status().is_success() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + format!("UserInfo returned status {}", resp.status()), + )); + } + + let info: UserInfoResponse = resp.json().await.map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to parse UserInfo: {}", e), + ) + })?; + + Ok(OidcIdClaims { + sub: info.sub, + email: info.email, + preferred_username: info.preferred_username, + name: info.name, + groups: info.groups.unwrap_or_default(), + }) + } + + fn provider_name(&self) -> &str { + &self.config.provider_name + } +} + +// We need urlencoding — let's use a minimal inline implementation +mod urlencoding { + pub fn encode(input: &str) -> String { + let mut result = String::with_capacity(input.len() * 3); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push('%'); + result.push_str(&format!("{:02X}", byte)); + } + } + } + result + } +} diff --git a/src/infrastructure/services/password_hasher.rs b/src/infrastructure/services/password_hasher.rs index 3cd77fca..c4f829f0 100644 --- a/src/infrastructure/services/password_hasher.rs +++ b/src/infrastructure/services/password_hasher.rs @@ -1,91 +1,115 @@ -//! Argon2-based password hasher implementation. -//! -//! This module provides a secure password hashing implementation using the Argon2id -//! algorithm, which is the recommended choice for password hashing as of 2023+. - -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use argon2::password_hash::SaltString; -use rand_core::OsRng; - -use crate::application::ports::auth_ports::PasswordHasherPort; -use crate::common::errors::{DomainError, ErrorKind}; - -/// Argon2-based implementation of the PasswordHasherPort. -/// -/// Uses Argon2id algorithm which provides resistance against both side-channel -/// and GPU-based attacks. This is the recommended algorithm for password hashing. -#[derive(Debug, Clone)] -pub struct Argon2PasswordHasher { - /// Argon2 hasher instance - uses default secure parameters - _private: (), -} - -impl Argon2PasswordHasher { - /// Create a new Argon2PasswordHasher with default secure parameters. - pub fn new() -> Self { - Self { _private: () } - } -} - -impl Default for Argon2PasswordHasher { - fn default() -> Self { - Self::new() - } -} - -impl PasswordHasherPort for Argon2PasswordHasher { - fn hash_password(&self, password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - - argon2.hash_password(password.as_bytes(), &salt) - .map(|hash| hash.to_string()) - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "PasswordHasher", - format!("Error generating password hash: {}", e) - )) - } - - fn verify_password(&self, password: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash) - .map_err(|e| DomainError::new( - ErrorKind::InternalError, - "PasswordHasher", - format!("Error processing password hash: {}", e) - ))?; - - Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hash_and_verify_password() { - let hasher = Argon2PasswordHasher::new(); - let password = "test_password_123"; - - let hash = hasher.hash_password(password).expect("Should hash password"); - assert!(hasher.verify_password(password, &hash).expect("Should verify")); - assert!(!hasher.verify_password("wrong_password", &hash).expect("Should verify")); - } - - #[test] - fn test_different_hashes_for_same_password() { - let hasher = Argon2PasswordHasher::new(); - let password = "same_password"; - - let hash1 = hasher.hash_password(password).expect("Should hash"); - let hash2 = hasher.hash_password(password).expect("Should hash"); - - // Hashes should be different due to random salt - assert_ne!(hash1, hash2); - - // But both should verify correctly - assert!(hasher.verify_password(password, &hash1).expect("Should verify")); - assert!(hasher.verify_password(password, &hash2).expect("Should verify")); - } -} +//! Argon2-based password hasher implementation. +//! +//! This module provides a secure password hashing implementation using the Argon2id +//! algorithm, which is the recommended choice for password hashing as of 2023+. + +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use rand_core::OsRng; + +use crate::application::ports::auth_ports::PasswordHasherPort; +use crate::common::errors::{DomainError, ErrorKind}; + +/// Argon2-based implementation of the PasswordHasherPort. +/// +/// Uses Argon2id algorithm which provides resistance against both side-channel +/// and GPU-based attacks. This is the recommended algorithm for password hashing. +#[derive(Debug, Clone)] +pub struct Argon2PasswordHasher { + /// Argon2 hasher instance - uses default secure parameters + _private: (), +} + +impl Argon2PasswordHasher { + /// Create a new Argon2PasswordHasher with default secure parameters. + pub fn new() -> Self { + Self { _private: () } + } +} + +impl Default for Argon2PasswordHasher { + fn default() -> Self { + Self::new() + } +} + +impl PasswordHasherPort for Argon2PasswordHasher { + fn hash_password(&self, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + argon2 + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "PasswordHasher", + format!("Error generating password hash: {}", e), + ) + }) + } + + fn verify_password(&self, password: &str, hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash).map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "PasswordHasher", + format!("Error processing password hash: {}", e), + ) + })?; + + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify_password() { + let hasher = Argon2PasswordHasher::new(); + let password = "test_password_123"; + + let hash = hasher + .hash_password(password) + .expect("Should hash password"); + assert!( + hasher + .verify_password(password, &hash) + .expect("Should verify") + ); + assert!( + !hasher + .verify_password("wrong_password", &hash) + .expect("Should verify") + ); + } + + #[test] + fn test_different_hashes_for_same_password() { + let hasher = Argon2PasswordHasher::new(); + let password = "same_password"; + + let hash1 = hasher.hash_password(password).expect("Should hash"); + let hash2 = hasher.hash_password(password).expect("Should hash"); + + // Hashes should be different due to random salt + assert_ne!(hash1, hash2); + + // But both should verify correctly + assert!( + hasher + .verify_password(password, &hash1) + .expect("Should verify") + ); + assert!( + hasher + .verify_password(password, &hash2) + .expect("Should verify") + ); + } +} diff --git a/src/infrastructure/services/path_service.rs b/src/infrastructure/services/path_service.rs index 1149fef8..9679aa7d 100644 --- a/src/infrastructure/services/path_service.rs +++ b/src/infrastructure/services/path_service.rs @@ -1,301 +1,340 @@ -//! PathService - Infrastructure service for storage path management -//! -//! This service was moved from domain/services because it implements application traits -//! (StoragePort, StorageMediator) and has file system dependencies (tokio::fs). -//! -//! StoragePath (Value Object) remains in domain/services/path_service.rs - -use std::path::{Path, PathBuf}; -use async_trait::async_trait; -use tokio::fs; - -use crate::common::errors::{DomainError, ErrorKind}; -use crate::application::ports::outbound::StoragePort; -use crate::application::services::storage_mediator::{StorageMediator, StorageMediatorResult, StorageMediatorError}; -use crate::domain::entities::folder::Folder; -use crate::domain::services::path_service::StoragePath; - -/// Infrastructure service for handling storage path operations -pub struct PathService { - root_path: PathBuf, -} - -impl PathService { - /// Creates a new path service with a specific root - pub fn new(root_path: PathBuf) -> Self { - Self { root_path } - } - - /// Converts a domain path to an absolute physical path - pub fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { - let mut path = self.root_path.clone(); - for segment in storage_path.segments() { - path.push(segment); - } - path - } - - /// Converts a physical path to a domain path - pub fn to_storage_path(&self, physical_path: &Path) -> Option { - physical_path.strip_prefix(&self.root_path).ok().map(|rel_path| { - let segments: Vec = rel_path - .components() - .filter_map(|c| match c { - std::path::Component::Normal(os_str) => Some(os_str.to_string_lossy().to_string()), - _ => None, - }) - .collect(); - StoragePath::new(segments) - }) - } - - /// Creates a file path within a folder - pub fn create_file_path(&self, folder_path: &StoragePath, file_name: &str) -> StoragePath { - folder_path.join(file_name) - } - - /// Checks if a path is a direct child of another - pub fn is_direct_child(&self, parent_path: &StoragePath, potential_child: &StoragePath) -> bool { - if let Some(child_parent) = potential_child.parent() { - &child_parent == parent_path - } else { - parent_path.is_empty() - } - } - - /// Checks if a path is at the root - pub fn is_in_root(&self, path: &StoragePath) -> bool { - path.parent().is_none_or(|p| p.is_empty()) - } - - /// Gets the root path used by this service - pub fn get_root_path(&self) -> &Path { - &self.root_path - } - - /// Validates a path to ensure it doesn't contain dangerous components - pub fn validate_path(&self, path: &StoragePath) -> Result<(), DomainError> { - // Check for empty segments - if path.segments().iter().any(|s| s.is_empty()) { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Path", - format!("Path contains empty segments: {}", path.to_string()) - )); - } - - // Check for dangerous characters - let dangerous_chars = ['\\', ':', '*', '?', '"', '<', '>', '|']; - for segment in path.segments() { - if segment.contains(&dangerous_chars[..]) { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Path", - format!("Path contains dangerous characters: {}", segment) - )); - } - - // Check that it doesn't start with . (hidden in Unix) - if segment.starts_with('.') && segment != ".well-known" { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Path", - format!("Path segments cannot start with dot: {}", segment) - )); - } - } - - Ok(()) - } -} - -#[async_trait] -impl StoragePort for PathService { - fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { - let mut path = self.root_path.clone(); - for segment in storage_path.segments() { - path.push(segment); - } - path - } - - async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError> { - // First validate the path - self.validate_path(storage_path)?; - - // Resolve to physical path - let physical_path = self.resolve_path(storage_path); - - // Create directories if they don't exist - if !physical_path.exists() { - fs::create_dir_all(&physical_path).await - .map_err(|e| DomainError::new( - ErrorKind::AccessDenied, - "Storage", - format!("Failed to create directory: {}", physical_path.display()) - ).with_source(e))?; - - tracing::debug!("Created directory: {}", physical_path.display()); - } else if !physical_path.is_dir() { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Storage", - format!("Path exists but is not a directory: {}", physical_path.display()) - )); - } - - Ok(()) - } - - async fn file_exists(&self, storage_path: &StoragePath) -> Result { - let physical_path = self.resolve_path(storage_path); - - let exists = physical_path.exists() && physical_path.is_file(); - Ok(exists) - } - - async fn directory_exists(&self, storage_path: &StoragePath) -> Result { - let physical_path = self.resolve_path(storage_path); - - let exists = physical_path.exists() && physical_path.is_dir(); - Ok(exists) - } -} - -#[async_trait] -impl StorageMediator for PathService { - async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult { - // This is a simplified implementation since PathService doesn't have direct - // access to folder repository. It's typically used through a proxy. - Err(StorageMediatorError::NotFound(format!("Folder with ID {} not found", folder_id))) - } - - async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult { - // Simplified implementation - should be overridden by actual implementations - Err(StorageMediatorError::NotFound(format!("Folder with ID {} not found", folder_id))) - } - - async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult { - // Simplified implementation - should be overridden by actual implementations - Err(StorageMediatorError::NotFound(format!("Folder with ID {} not found", folder_id))) - } - - async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult { - let abs_path = self.resolve_path(&StoragePath::from_string(&path.to_string_lossy())); - Ok(abs_path.exists() && abs_path.is_file()) - } - - async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult { - let abs_path = self.resolve_path(storage_path); - Ok(abs_path.exists() && abs_path.is_file()) - } - - async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult { - let abs_path = self.resolve_path(&StoragePath::from_string(&path.to_string_lossy())); - Ok(abs_path.exists() && abs_path.is_dir()) - } - - async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult { - let abs_path = self.resolve_path(storage_path); - Ok(abs_path.exists() && abs_path.is_dir()) - } - - fn resolve_path(&self, relative_path: &Path) -> PathBuf { - // Convert path to storage path then resolve - let path_str = relative_path.to_string_lossy().to_string(); - let storage_path = StoragePath::from_string(&path_str); - PathService::resolve_path(self, &storage_path) - } - - fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { - PathService::resolve_path(self, storage_path) - } - - async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()> { - let abs_path = PathService::resolve_path(self, &StoragePath::from_string(&path.to_string_lossy())); - - if !abs_path.exists() { - fs::create_dir_all(&abs_path).await - .map_err(|e| StorageMediatorError::AccessError(format!("Failed to create directory: {}", e)))?; - } else if !abs_path.is_dir() { - return Err(StorageMediatorError::InvalidPath( - format!("Path exists but is not a directory: {}", abs_path.display()) - )); - } - - Ok(()) - } - - async fn ensure_storage_directory(&self, storage_path: &StoragePath) -> StorageMediatorResult<()> { - let abs_path = PathService::resolve_path(self, storage_path); - - if !abs_path.exists() { - fs::create_dir_all(&abs_path).await - .map_err(|e| StorageMediatorError::AccessError(format!("Failed to create directory: {}", e)))?; - } else if !abs_path.is_dir() { - return Err(StorageMediatorError::InvalidPath( - format!("Path exists but is not a directory: {}", abs_path.display()) - )); - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resolve_path() { - let service = PathService::new(PathBuf::from("/storage")); - - let storage_path = StoragePath::from_string("test/file.txt"); - let absolute = service.resolve_path(&storage_path); - - assert_eq!(absolute, PathBuf::from("/storage/test/file.txt")); - } - - #[test] - fn test_to_storage_path() { - let service = PathService::new(PathBuf::from("/storage")); - - let physical_path = PathBuf::from("/storage/folder/file.txt"); - let storage_path = service.to_storage_path(&physical_path).unwrap(); - - assert_eq!(storage_path.to_string(), "/folder/file.txt"); - } - - #[test] - fn test_is_in_root() { - let service = PathService::new(PathBuf::from("/storage")); - - let root_path = StoragePath::from_string("file.txt"); - let nested_path = StoragePath::from_string("folder/file.txt"); - - assert!(service.is_in_root(&root_path)); - assert!(!service.is_in_root(&nested_path)); - } - - #[test] - fn test_is_direct_child() { - let service = PathService::new(PathBuf::from("/storage")); - - let parent = StoragePath::from_string("folder"); - let child = StoragePath::from_string("folder/file.txt"); - let not_child = StoragePath::from_string("folder2/file.txt"); - - assert!(service.is_direct_child(&parent, &child)); - assert!(!service.is_direct_child(&parent, ¬_child)); - } - - #[test] - fn test_create_file_path() { - let service = PathService::new(PathBuf::from("/storage")); - - let folder_path = StoragePath::from_string("folder"); - let file_path = service.create_file_path(&folder_path, "file.txt"); - - assert_eq!(file_path.to_string(), "/folder/file.txt"); - } -} +//! PathService - Infrastructure service for storage path management +//! +//! This service was moved from domain/services because it implements application traits +//! (StoragePort, StorageMediator) and has file system dependencies (tokio::fs). +//! +//! StoragePath (Value Object) remains in domain/services/path_service.rs + +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use tokio::fs; + +use crate::application::ports::outbound::StoragePort; +use crate::application::services::storage_mediator::{ + StorageMediator, StorageMediatorError, StorageMediatorResult, +}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::folder::Folder; +use crate::domain::services::path_service::StoragePath; + +/// Infrastructure service for handling storage path operations +pub struct PathService { + root_path: PathBuf, +} + +impl PathService { + /// Creates a new path service with a specific root + pub fn new(root_path: PathBuf) -> Self { + Self { root_path } + } + + /// Converts a domain path to an absolute physical path + pub fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { + let mut path = self.root_path.clone(); + for segment in storage_path.segments() { + path.push(segment); + } + path + } + + /// Converts a physical path to a domain path + pub fn to_storage_path(&self, physical_path: &Path) -> Option { + physical_path + .strip_prefix(&self.root_path) + .ok() + .map(|rel_path| { + let segments: Vec = rel_path + .components() + .filter_map(|c| match c { + std::path::Component::Normal(os_str) => { + Some(os_str.to_string_lossy().to_string()) + } + _ => None, + }) + .collect(); + StoragePath::new(segments) + }) + } + + /// Creates a file path within a folder + pub fn create_file_path(&self, folder_path: &StoragePath, file_name: &str) -> StoragePath { + folder_path.join(file_name) + } + + /// Checks if a path is a direct child of another + pub fn is_direct_child( + &self, + parent_path: &StoragePath, + potential_child: &StoragePath, + ) -> bool { + if let Some(child_parent) = potential_child.parent() { + &child_parent == parent_path + } else { + parent_path.is_empty() + } + } + + /// Checks if a path is at the root + pub fn is_in_root(&self, path: &StoragePath) -> bool { + path.parent().is_none_or(|p| p.is_empty()) + } + + /// Gets the root path used by this service + pub fn get_root_path(&self) -> &Path { + &self.root_path + } + + /// Validates a path to ensure it doesn't contain dangerous components + pub fn validate_path(&self, path: &StoragePath) -> Result<(), DomainError> { + // Check for empty segments + if path.segments().iter().any(|s| s.is_empty()) { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Path", + format!("Path contains empty segments: {}", path.to_string()), + )); + } + + // Check for dangerous characters + let dangerous_chars = ['\\', ':', '*', '?', '"', '<', '>', '|']; + for segment in path.segments() { + if segment.contains(&dangerous_chars[..]) { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Path", + format!("Path contains dangerous characters: {}", segment), + )); + } + + // Check that it doesn't start with . (hidden in Unix) + if segment.starts_with('.') && segment != ".well-known" { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Path", + format!("Path segments cannot start with dot: {}", segment), + )); + } + } + + Ok(()) + } +} + +#[async_trait] +impl StoragePort for PathService { + fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { + let mut path = self.root_path.clone(); + for segment in storage_path.segments() { + path.push(segment); + } + path + } + + async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError> { + // First validate the path + self.validate_path(storage_path)?; + + // Resolve to physical path + let physical_path = self.resolve_path(storage_path); + + // Create directories if they don't exist + if !physical_path.exists() { + fs::create_dir_all(&physical_path).await.map_err(|e| { + DomainError::new( + ErrorKind::AccessDenied, + "Storage", + format!("Failed to create directory: {}", physical_path.display()), + ) + .with_source(e) + })?; + + tracing::debug!("Created directory: {}", physical_path.display()); + } else if !physical_path.is_dir() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Storage", + format!( + "Path exists but is not a directory: {}", + physical_path.display() + ), + )); + } + + Ok(()) + } + + async fn file_exists(&self, storage_path: &StoragePath) -> Result { + let physical_path = self.resolve_path(storage_path); + + let exists = physical_path.exists() && physical_path.is_file(); + Ok(exists) + } + + async fn directory_exists(&self, storage_path: &StoragePath) -> Result { + let physical_path = self.resolve_path(storage_path); + + let exists = physical_path.exists() && physical_path.is_dir(); + Ok(exists) + } +} + +#[async_trait] +impl StorageMediator for PathService { + async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult { + // This is a simplified implementation since PathService doesn't have direct + // access to folder repository. It's typically used through a proxy. + Err(StorageMediatorError::NotFound(format!( + "Folder with ID {} not found", + folder_id + ))) + } + + async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult { + // Simplified implementation - should be overridden by actual implementations + Err(StorageMediatorError::NotFound(format!( + "Folder with ID {} not found", + folder_id + ))) + } + + async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult { + // Simplified implementation - should be overridden by actual implementations + Err(StorageMediatorError::NotFound(format!( + "Folder with ID {} not found", + folder_id + ))) + } + + async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult { + let abs_path = self.resolve_path(&StoragePath::from_string(&path.to_string_lossy())); + Ok(abs_path.exists() && abs_path.is_file()) + } + + async fn file_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult { + let abs_path = self.resolve_path(storage_path); + Ok(abs_path.exists() && abs_path.is_file()) + } + + async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult { + let abs_path = self.resolve_path(&StoragePath::from_string(&path.to_string_lossy())); + Ok(abs_path.exists() && abs_path.is_dir()) + } + + async fn folder_exists_at_storage_path( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult { + let abs_path = self.resolve_path(storage_path); + Ok(abs_path.exists() && abs_path.is_dir()) + } + + fn resolve_path(&self, relative_path: &Path) -> PathBuf { + // Convert path to storage path then resolve + let path_str = relative_path.to_string_lossy().to_string(); + let storage_path = StoragePath::from_string(&path_str); + PathService::resolve_path(self, &storage_path) + } + + fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf { + PathService::resolve_path(self, storage_path) + } + + async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()> { + let abs_path = + PathService::resolve_path(self, &StoragePath::from_string(&path.to_string_lossy())); + + if !abs_path.exists() { + fs::create_dir_all(&abs_path).await.map_err(|e| { + StorageMediatorError::AccessError(format!("Failed to create directory: {}", e)) + })?; + } else if !abs_path.is_dir() { + return Err(StorageMediatorError::InvalidPath(format!( + "Path exists but is not a directory: {}", + abs_path.display() + ))); + } + + Ok(()) + } + + async fn ensure_storage_directory( + &self, + storage_path: &StoragePath, + ) -> StorageMediatorResult<()> { + let abs_path = PathService::resolve_path(self, storage_path); + + if !abs_path.exists() { + fs::create_dir_all(&abs_path).await.map_err(|e| { + StorageMediatorError::AccessError(format!("Failed to create directory: {}", e)) + })?; + } else if !abs_path.is_dir() { + return Err(StorageMediatorError::InvalidPath(format!( + "Path exists but is not a directory: {}", + abs_path.display() + ))); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_path() { + let service = PathService::new(PathBuf::from("/storage")); + + let storage_path = StoragePath::from_string("test/file.txt"); + let absolute = service.resolve_path(&storage_path); + + assert_eq!(absolute, PathBuf::from("/storage/test/file.txt")); + } + + #[test] + fn test_to_storage_path() { + let service = PathService::new(PathBuf::from("/storage")); + + let physical_path = PathBuf::from("/storage/folder/file.txt"); + let storage_path = service.to_storage_path(&physical_path).unwrap(); + + assert_eq!(storage_path.to_string(), "/folder/file.txt"); + } + + #[test] + fn test_is_in_root() { + let service = PathService::new(PathBuf::from("/storage")); + + let root_path = StoragePath::from_string("file.txt"); + let nested_path = StoragePath::from_string("folder/file.txt"); + + assert!(service.is_in_root(&root_path)); + assert!(!service.is_in_root(&nested_path)); + } + + #[test] + fn test_is_direct_child() { + let service = PathService::new(PathBuf::from("/storage")); + + let parent = StoragePath::from_string("folder"); + let child = StoragePath::from_string("folder/file.txt"); + let not_child = StoragePath::from_string("folder2/file.txt"); + + assert!(service.is_direct_child(&parent, &child)); + assert!(!service.is_direct_child(&parent, ¬_child)); + } + + #[test] + fn test_create_file_path() { + let service = PathService::new(PathBuf::from("/storage")); + + let folder_path = StoragePath::from_string("folder"); + let file_path = service.create_file_path(&folder_path, "file.txt"); + + assert_eq!(file_path.to_string(), "/folder/file.txt"); + } +} diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 99d7befe..b2adf31e 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -1,416 +1,420 @@ -/** - * Thumbnail Generation Service - * - * Generates and manages image thumbnails for fast gallery previews. - * - * Features: - * - Background thumbnail generation after upload - * - Multiple sizes (icon 150x150, preview 800x600) - * - WebP output for smaller file sizes - * - LRU cache for hot thumbnails - * - Lazy generation on first request if not pre-generated - */ - -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio::fs; -use image::{ImageFormat, imageops::FilterType}; -use lru::LruCache; -use std::num::NonZeroUsize; -use bytes::Bytes; -use async_trait::async_trait; - -use crate::application::ports::thumbnail_ports::{ - ThumbnailPort, - ThumbnailSize as PortThumbnailSize, - ThumbnailStatsDto, -}; -use crate::domain::errors::{DomainError, ErrorKind}; - -/// Thumbnail sizes supported by the system -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ThumbnailSize { - /// Small icon for file listings (150x150) - Icon, - /// Medium preview for gallery view (400x400) - Preview, - /// Large preview for detail view (800x800) - Large, -} - -impl ThumbnailSize { - /// Get the maximum dimension for this size - pub fn max_dimension(&self) -> u32 { - match self { - ThumbnailSize::Icon => 150, - ThumbnailSize::Preview => 400, - ThumbnailSize::Large => 800, - } - } - - /// Get the directory name for this size - pub fn dir_name(&self) -> &'static str { - match self { - ThumbnailSize::Icon => "icon", - ThumbnailSize::Preview => "preview", - ThumbnailSize::Large => "large", - } - } - - /// Get all thumbnail sizes - pub fn all() -> &'static [ThumbnailSize] { - &[ThumbnailSize::Icon, ThumbnailSize::Preview, ThumbnailSize::Large] - } -} - -/// Cache key for thumbnails -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct ThumbnailCacheKey { - file_id: String, - size: ThumbnailSize, -} - -/// Thumbnail service for generating and caching image thumbnails -pub struct ThumbnailService { - /// Root path for thumbnail storage - thumbnails_root: PathBuf, - /// In-memory LRU cache for hot thumbnails - cache: Arc>>, - /// Maximum cache size in bytes - max_cache_bytes: usize, - /// Current cache size in bytes - current_cache_bytes: Arc>, -} - -impl ThumbnailService { - /// Create a new thumbnail service - /// - /// # Arguments - /// * `storage_root` - Root path of file storage - /// * `max_cache_entries` - Maximum number of thumbnails to cache in memory - /// * `max_cache_bytes` - Maximum total bytes to cache - pub fn new(storage_root: &Path, max_cache_entries: usize, max_cache_bytes: usize) -> Self { - let thumbnails_root = storage_root.join(".thumbnails"); - - Self { - thumbnails_root, - cache: Arc::new(RwLock::new(LruCache::new( - NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()) - ))), - max_cache_bytes, - current_cache_bytes: Arc::new(RwLock::new(0)), - } - } - - /// Initialize the thumbnail directories - pub async fn initialize(&self) -> std::io::Result<()> { - for size in ThumbnailSize::all() { - let dir = self.thumbnails_root.join(size.dir_name()); - fs::create_dir_all(&dir).await?; - } - tracing::info!("🖼️ Thumbnail service initialized at {:?}", self.thumbnails_root); - Ok(()) - } - - /// Check if a file is an image that can have thumbnails - pub fn is_supported_image(mime_type: &str) -> bool { - matches!( - mime_type, - "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" - ) - } - - /// Get the path where a thumbnail would be stored - fn get_thumbnail_path(&self, file_id: &str, size: ThumbnailSize) -> PathBuf { - self.thumbnails_root - .join(size.dir_name()) - .join(format!("{}.webp", file_id)) - } - - /// Check if a thumbnail exists on disk - pub async fn thumbnail_exists(&self, file_id: &str, size: ThumbnailSize) -> bool { - let path = self.get_thumbnail_path(file_id, size); - fs::metadata(&path).await.is_ok() - } - - /// Get a thumbnail, generating it if needed - /// - /// # Arguments - /// * `file_id` - ID of the original file - /// * `size` - Desired thumbnail size - /// * `original_path` - Path to the original image file - /// - /// # Returns - /// Bytes of the thumbnail image (WebP format) - pub async fn get_thumbnail( - &self, - file_id: &str, - size: ThumbnailSize, - original_path: &Path, - ) -> Result { - let cache_key = ThumbnailCacheKey { - file_id: file_id.to_string(), - size, - }; - - // Check in-memory cache first - { - let cache = self.cache.read().await; - if let Some(data) = cache.peek(&cache_key) { - tracing::debug!("🔥 Thumbnail cache HIT: {} {:?}", file_id, size); - return Ok(data.clone()); - } - } - - // Check if thumbnail exists on disk - let thumb_path = self.get_thumbnail_path(file_id, size); - - if fs::metadata(&thumb_path).await.is_ok() { - // Load from disk - let data = fs::read(&thumb_path).await - .map_err(|e| ThumbnailError::IoError(e.to_string()))?; - let bytes = Bytes::from(data); - - // Add to cache - self.add_to_cache(cache_key, bytes.clone()).await; - - tracing::debug!("💾 Thumbnail loaded from disk: {} {:?}", file_id, size); - return Ok(bytes); - } - - // Generate thumbnail - tracing::info!("🎨 Generating thumbnail: {} {:?}", file_id, size); - let bytes = self.generate_thumbnail(original_path, size).await?; - - // Save to disk - if let Some(parent) = thumb_path.parent() { - fs::create_dir_all(parent).await - .map_err(|e| ThumbnailError::IoError(e.to_string()))?; - } - fs::write(&thumb_path, &bytes).await - .map_err(|e| ThumbnailError::IoError(e.to_string()))?; - - // Add to cache - self.add_to_cache(cache_key, bytes.clone()).await; - - Ok(bytes) - } - - /// Generate a thumbnail from an image file - async fn generate_thumbnail( - &self, - original_path: &Path, - size: ThumbnailSize, - ) -> Result { - let path = original_path.to_path_buf(); - let max_dim = size.max_dimension(); - - // Run image processing in blocking thread pool - let result = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { - // Load image - let img = image::open(&path) - .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - - // Calculate new dimensions preserving aspect ratio - let (orig_width, orig_height) = (img.width(), img.height()); - let (new_width, new_height) = if orig_width > orig_height { - let ratio = max_dim as f32 / orig_width as f32; - (max_dim, (orig_height as f32 * ratio) as u32) - } else { - let ratio = max_dim as f32 / orig_height as f32; - ((orig_width as f32 * ratio) as u32, max_dim) - }; - - // Resize using high-quality Lanczos3 filter - let thumbnail = img.resize(new_width, new_height, FilterType::Lanczos3); - - // Encode as WebP for smaller file size - let mut buffer = Vec::new(); - thumbnail.write_to( - &mut std::io::Cursor::new(&mut buffer), - ImageFormat::WebP - ).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - - Ok(buffer) - }).await - .map_err(|e| ThumbnailError::TaskError(e.to_string()))?; - - result.map(Bytes::from) - } - - /// Add a thumbnail to the in-memory cache - async fn add_to_cache(&self, key: ThumbnailCacheKey, data: Bytes) { - let data_size = data.len(); - - // Check if adding this would exceed max cache size - let mut current_size = self.current_cache_bytes.write().await; - - // Evict items if needed to make room - if *current_size + data_size > self.max_cache_bytes { - let mut cache = self.cache.write().await; - while *current_size + data_size > self.max_cache_bytes && !cache.is_empty() { - if let Some((_, evicted)) = cache.pop_lru() { - *current_size = current_size.saturating_sub(evicted.len()); - } - } - } - - // Add to cache - let mut cache = self.cache.write().await; - if let Some(old) = cache.put(key, data) { - *current_size = current_size.saturating_sub(old.len()); - } - *current_size += data_size; - } - - /// Generate all thumbnail sizes for a file in the background - /// - /// This is called after file upload to pre-generate thumbnails - pub fn generate_all_sizes_background( - self: Arc, - file_id: String, - original_path: PathBuf, - ) { - tokio::spawn(async move { - tracing::info!("🖼️ Background thumbnail generation starting: {}", file_id); - - for size in ThumbnailSize::all() { - match self.generate_thumbnail(&original_path, *size).await { - Ok(bytes) => { - // Save to disk - let thumb_path = self.get_thumbnail_path(&file_id, *size); - if let Some(parent) = thumb_path.parent() { - let _ = fs::create_dir_all(parent).await; - } - if let Err(e) = fs::write(&thumb_path, &bytes).await { - tracing::warn!("Failed to save thumbnail {}: {}", file_id, e); - } else { - tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size); - } - }, - Err(e) => { - tracing::warn!("Failed to generate thumbnail {} {:?}: {}", file_id, size, e); - } - } - } - - tracing::info!("✅ Background thumbnail generation complete: {}", file_id); - }); - } - - /// Delete all thumbnails for a file - pub async fn delete_thumbnails(&self, file_id: &str) -> Result<(), ThumbnailError> { - for size in ThumbnailSize::all() { - let path = self.get_thumbnail_path(file_id, *size); - if fs::metadata(&path).await.is_ok() { - fs::remove_file(&path).await - .map_err(|e| ThumbnailError::IoError(e.to_string()))?; - } - - // Remove from cache - let cache_key = ThumbnailCacheKey { - file_id: file_id.to_string(), - size: *size, - }; - let mut cache = self.cache.write().await; - if let Some(removed) = cache.pop(&cache_key) { - let mut current_size = self.current_cache_bytes.write().await; - *current_size = current_size.saturating_sub(removed.len()); - } - } - - tracing::debug!("🗑️ Deleted thumbnails for: {}", file_id); - Ok(()) - } - - /// Get cache statistics - pub async fn get_stats(&self) -> ThumbnailStats { - let cache = self.cache.read().await; - let current_size = *self.current_cache_bytes.read().await; - - ThumbnailStats { - cached_thumbnails: cache.len(), - cache_size_bytes: current_size, - max_cache_bytes: self.max_cache_bytes, - } - } -} - -// ─── Port implementation ───────────────────────────────────────────────────── - -/// Convert port ThumbnailSize to infra ThumbnailSize. -impl From for ThumbnailSize { - fn from(size: PortThumbnailSize) -> Self { - match size { - PortThumbnailSize::Icon => ThumbnailSize::Icon, - PortThumbnailSize::Preview => ThumbnailSize::Preview, - PortThumbnailSize::Large => ThumbnailSize::Large, - } - } -} - -#[async_trait] -impl ThumbnailPort for ThumbnailService { - fn is_supported_image(&self, mime_type: &str) -> bool { - ThumbnailService::is_supported_image(mime_type) - } - - async fn get_thumbnail( - &self, - file_id: &str, - size: PortThumbnailSize, - original_path: &Path, - ) -> Result { - self.get_thumbnail(file_id, size.into(), original_path) - .await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) - } - - fn generate_all_sizes_background( - self: Arc, - file_id: String, - original_path: PathBuf, - ) { - ThumbnailService::generate_all_sizes_background(self, file_id, original_path) - } - - async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError> { - self.delete_thumbnails(file_id) - .await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) - } - - async fn get_stats(&self) -> ThumbnailStatsDto { - let stats = self.get_stats().await; - ThumbnailStatsDto { - cached_thumbnails: stats.cached_thumbnails, - cache_size_bytes: stats.cache_size_bytes, - max_cache_bytes: stats.max_cache_bytes, - } - } -} - -/// Thumbnail service errors -#[derive(Debug, thiserror::Error)] -pub enum ThumbnailError { - #[error("IO error: {0}")] - IoError(String), - - #[error("Image processing error: {0}")] - ImageError(String), - - #[error("Task error: {0}")] - TaskError(String), - - #[error("Unsupported image format")] - UnsupportedFormat, -} - -/// Statistics about the thumbnail cache -#[derive(Debug, Clone)] -pub struct ThumbnailStats { - pub cached_thumbnails: usize, - pub cache_size_bytes: usize, - pub max_cache_bytes: usize, -} +use async_trait::async_trait; +use bytes::Bytes; +use image::{ImageFormat, imageops::FilterType}; +use lru::LruCache; +use std::num::NonZeroUsize; +/** + * Thumbnail Generation Service + * + * Generates and manages image thumbnails for fast gallery previews. + * + * Features: + * - Background thumbnail generation after upload + * - Multiple sizes (icon 150x150, preview 800x600) + * - WebP output for smaller file sizes + * - LRU cache for hot thumbnails + * - Lazy generation on first request if not pre-generated + */ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs; +use tokio::sync::RwLock; + +use crate::application::ports::thumbnail_ports::{ + ThumbnailPort, ThumbnailSize as PortThumbnailSize, ThumbnailStatsDto, +}; +use crate::domain::errors::{DomainError, ErrorKind}; + +/// Thumbnail sizes supported by the system +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ThumbnailSize { + /// Small icon for file listings (150x150) + Icon, + /// Medium preview for gallery view (400x400) + Preview, + /// Large preview for detail view (800x800) + Large, +} + +impl ThumbnailSize { + /// Get the maximum dimension for this size + pub fn max_dimension(&self) -> u32 { + match self { + ThumbnailSize::Icon => 150, + ThumbnailSize::Preview => 400, + ThumbnailSize::Large => 800, + } + } + + /// Get the directory name for this size + pub fn dir_name(&self) -> &'static str { + match self { + ThumbnailSize::Icon => "icon", + ThumbnailSize::Preview => "preview", + ThumbnailSize::Large => "large", + } + } + + /// Get all thumbnail sizes + pub fn all() -> &'static [ThumbnailSize] { + &[ + ThumbnailSize::Icon, + ThumbnailSize::Preview, + ThumbnailSize::Large, + ] + } +} + +/// Cache key for thumbnails +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ThumbnailCacheKey { + file_id: String, + size: ThumbnailSize, +} + +/// Thumbnail service for generating and caching image thumbnails +pub struct ThumbnailService { + /// Root path for thumbnail storage + thumbnails_root: PathBuf, + /// In-memory LRU cache for hot thumbnails + cache: Arc>>, + /// Maximum cache size in bytes + max_cache_bytes: usize, + /// Current cache size in bytes + current_cache_bytes: Arc>, +} + +impl ThumbnailService { + /// Create a new thumbnail service + /// + /// # Arguments + /// * `storage_root` - Root path of file storage + /// * `max_cache_entries` - Maximum number of thumbnails to cache in memory + /// * `max_cache_bytes` - Maximum total bytes to cache + pub fn new(storage_root: &Path, max_cache_entries: usize, max_cache_bytes: usize) -> Self { + let thumbnails_root = storage_root.join(".thumbnails"); + + Self { + thumbnails_root, + cache: Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()), + ))), + max_cache_bytes, + current_cache_bytes: Arc::new(RwLock::new(0)), + } + } + + /// Initialize the thumbnail directories + pub async fn initialize(&self) -> std::io::Result<()> { + for size in ThumbnailSize::all() { + let dir = self.thumbnails_root.join(size.dir_name()); + fs::create_dir_all(&dir).await?; + } + tracing::info!( + "🖼️ Thumbnail service initialized at {:?}", + self.thumbnails_root + ); + Ok(()) + } + + /// Check if a file is an image that can have thumbnails + pub fn is_supported_image(mime_type: &str) -> bool { + matches!( + mime_type, + "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" + ) + } + + /// Get the path where a thumbnail would be stored + fn get_thumbnail_path(&self, file_id: &str, size: ThumbnailSize) -> PathBuf { + self.thumbnails_root + .join(size.dir_name()) + .join(format!("{}.webp", file_id)) + } + + /// Check if a thumbnail exists on disk + pub async fn thumbnail_exists(&self, file_id: &str, size: ThumbnailSize) -> bool { + let path = self.get_thumbnail_path(file_id, size); + fs::metadata(&path).await.is_ok() + } + + /// Get a thumbnail, generating it if needed + /// + /// # Arguments + /// * `file_id` - ID of the original file + /// * `size` - Desired thumbnail size + /// * `original_path` - Path to the original image file + /// + /// # Returns + /// Bytes of the thumbnail image (WebP format) + pub async fn get_thumbnail( + &self, + file_id: &str, + size: ThumbnailSize, + original_path: &Path, + ) -> Result { + let cache_key = ThumbnailCacheKey { + file_id: file_id.to_string(), + size, + }; + + // Check in-memory cache first + { + let cache = self.cache.read().await; + if let Some(data) = cache.peek(&cache_key) { + tracing::debug!("🔥 Thumbnail cache HIT: {} {:?}", file_id, size); + return Ok(data.clone()); + } + } + + // Check if thumbnail exists on disk + let thumb_path = self.get_thumbnail_path(file_id, size); + + if fs::metadata(&thumb_path).await.is_ok() { + // Load from disk + let data = fs::read(&thumb_path) + .await + .map_err(|e| ThumbnailError::IoError(e.to_string()))?; + let bytes = Bytes::from(data); + + // Add to cache + self.add_to_cache(cache_key, bytes.clone()).await; + + tracing::debug!("💾 Thumbnail loaded from disk: {} {:?}", file_id, size); + return Ok(bytes); + } + + // Generate thumbnail + tracing::info!("🎨 Generating thumbnail: {} {:?}", file_id, size); + let bytes = self.generate_thumbnail(original_path, size).await?; + + // Save to disk + if let Some(parent) = thumb_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| ThumbnailError::IoError(e.to_string()))?; + } + fs::write(&thumb_path, &bytes) + .await + .map_err(|e| ThumbnailError::IoError(e.to_string()))?; + + // Add to cache + self.add_to_cache(cache_key, bytes.clone()).await; + + Ok(bytes) + } + + /// Generate a thumbnail from an image file + async fn generate_thumbnail( + &self, + original_path: &Path, + size: ThumbnailSize, + ) -> Result { + let path = original_path.to_path_buf(); + let max_dim = size.max_dimension(); + + // Run image processing in blocking thread pool + let result = tokio::task::spawn_blocking(move || -> Result, ThumbnailError> { + // Load image + let img = image::open(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + + // Calculate new dimensions preserving aspect ratio + let (orig_width, orig_height) = (img.width(), img.height()); + let (new_width, new_height) = if orig_width > orig_height { + let ratio = max_dim as f32 / orig_width as f32; + (max_dim, (orig_height as f32 * ratio) as u32) + } else { + let ratio = max_dim as f32 / orig_height as f32; + ((orig_width as f32 * ratio) as u32, max_dim) + }; + + // Resize using high-quality Lanczos3 filter + let thumbnail = img.resize(new_width, new_height, FilterType::Lanczos3); + + // Encode as WebP for smaller file size + let mut buffer = Vec::new(); + thumbnail + .write_to(&mut std::io::Cursor::new(&mut buffer), ImageFormat::WebP) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + + Ok(buffer) + }) + .await + .map_err(|e| ThumbnailError::TaskError(e.to_string()))?; + + result.map(Bytes::from) + } + + /// Add a thumbnail to the in-memory cache + async fn add_to_cache(&self, key: ThumbnailCacheKey, data: Bytes) { + let data_size = data.len(); + + // Check if adding this would exceed max cache size + let mut current_size = self.current_cache_bytes.write().await; + + // Evict items if needed to make room + if *current_size + data_size > self.max_cache_bytes { + let mut cache = self.cache.write().await; + while *current_size + data_size > self.max_cache_bytes && !cache.is_empty() { + if let Some((_, evicted)) = cache.pop_lru() { + *current_size = current_size.saturating_sub(evicted.len()); + } + } + } + + // Add to cache + let mut cache = self.cache.write().await; + if let Some(old) = cache.put(key, data) { + *current_size = current_size.saturating_sub(old.len()); + } + *current_size += data_size; + } + + /// Generate all thumbnail sizes for a file in the background + /// + /// This is called after file upload to pre-generate thumbnails + pub fn generate_all_sizes_background(self: Arc, file_id: String, original_path: PathBuf) { + tokio::spawn(async move { + tracing::info!("🖼️ Background thumbnail generation starting: {}", file_id); + + for size in ThumbnailSize::all() { + match self.generate_thumbnail(&original_path, *size).await { + Ok(bytes) => { + // Save to disk + let thumb_path = self.get_thumbnail_path(&file_id, *size); + if let Some(parent) = thumb_path.parent() { + let _ = fs::create_dir_all(parent).await; + } + if let Err(e) = fs::write(&thumb_path, &bytes).await { + tracing::warn!("Failed to save thumbnail {}: {}", file_id, e); + } else { + tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size); + } + } + Err(e) => { + tracing::warn!( + "Failed to generate thumbnail {} {:?}: {}", + file_id, + size, + e + ); + } + } + } + + tracing::info!("✅ Background thumbnail generation complete: {}", file_id); + }); + } + + /// Delete all thumbnails for a file + pub async fn delete_thumbnails(&self, file_id: &str) -> Result<(), ThumbnailError> { + for size in ThumbnailSize::all() { + let path = self.get_thumbnail_path(file_id, *size); + if fs::metadata(&path).await.is_ok() { + fs::remove_file(&path) + .await + .map_err(|e| ThumbnailError::IoError(e.to_string()))?; + } + + // Remove from cache + let cache_key = ThumbnailCacheKey { + file_id: file_id.to_string(), + size: *size, + }; + let mut cache = self.cache.write().await; + if let Some(removed) = cache.pop(&cache_key) { + let mut current_size = self.current_cache_bytes.write().await; + *current_size = current_size.saturating_sub(removed.len()); + } + } + + tracing::debug!("🗑️ Deleted thumbnails for: {}", file_id); + Ok(()) + } + + /// Get cache statistics + pub async fn get_stats(&self) -> ThumbnailStats { + let cache = self.cache.read().await; + let current_size = *self.current_cache_bytes.read().await; + + ThumbnailStats { + cached_thumbnails: cache.len(), + cache_size_bytes: current_size, + max_cache_bytes: self.max_cache_bytes, + } + } +} + +// ─── Port implementation ───────────────────────────────────────────────────── + +/// Convert port ThumbnailSize to infra ThumbnailSize. +impl From for ThumbnailSize { + fn from(size: PortThumbnailSize) -> Self { + match size { + PortThumbnailSize::Icon => ThumbnailSize::Icon, + PortThumbnailSize::Preview => ThumbnailSize::Preview, + PortThumbnailSize::Large => ThumbnailSize::Large, + } + } +} + +#[async_trait] +impl ThumbnailPort for ThumbnailService { + fn is_supported_image(&self, mime_type: &str) -> bool { + ThumbnailService::is_supported_image(mime_type) + } + + async fn get_thumbnail( + &self, + file_id: &str, + size: PortThumbnailSize, + original_path: &Path, + ) -> Result { + self.get_thumbnail(file_id, size.into(), original_path) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) + } + + fn generate_all_sizes_background(self: Arc, file_id: String, original_path: PathBuf) { + ThumbnailService::generate_all_sizes_background(self, file_id, original_path) + } + + async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError> { + self.delete_thumbnails(file_id) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Thumbnail", e.to_string())) + } + + async fn get_stats(&self) -> ThumbnailStatsDto { + let stats = self.get_stats().await; + ThumbnailStatsDto { + cached_thumbnails: stats.cached_thumbnails, + cache_size_bytes: stats.cache_size_bytes, + max_cache_bytes: stats.max_cache_bytes, + } + } +} + +/// Thumbnail service errors +#[derive(Debug, thiserror::Error)] +pub enum ThumbnailError { + #[error("IO error: {0}")] + IoError(String), + + #[error("Image processing error: {0}")] + ImageError(String), + + #[error("Task error: {0}")] + TaskError(String), + + #[error("Unsupported image format")] + UnsupportedFormat, +} + +/// Statistics about the thumbnail cache +#[derive(Debug, Clone)] +pub struct ThumbnailStats { + pub cached_thumbnails: usize, + pub cache_size_bytes: usize, + pub max_cache_bytes: usize, +} diff --git a/src/infrastructure/services/trash_cleanup_service.rs b/src/infrastructure/services/trash_cleanup_service.rs index 50a6426a..b756a5f4 100644 --- a/src/infrastructure/services/trash_cleanup_service.rs +++ b/src/infrastructure/services/trash_cleanup_service.rs @@ -3,9 +3,9 @@ use std::time::Duration; use tokio::time; use tracing::{debug, error, info, instrument}; +use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::Result; use crate::domain::repositories::trash_repository::TrashRepository; -use crate::application::ports::trash_ports::TrashUseCase; /// Service for automatic cleanup of expired items in the trash pub struct TrashCleanupService { @@ -26,38 +26,42 @@ impl TrashCleanupService { cleanup_interval_hours: cleanup_interval_hours.max(1), // Minimum 1 hour } } - + /// Starts the periodic cleanup job #[instrument(skip(self))] pub async fn start_cleanup_job(&self) { let trash_repository = self.trash_repository.clone(); let trash_service = self.trash_service.clone(); let interval_hours = self.cleanup_interval_hours; - - info!("Starting trash cleanup job with interval of {} hours", interval_hours); - + + info!( + "Starting trash cleanup job with interval of {} hours", + interval_hours + ); + tokio::spawn(async move { let interval_duration = Duration::from_secs(interval_hours * 60 * 60); let mut interval = time::interval(interval_duration); - + // First immediate execution - Self::cleanup_expired_items(trash_repository.clone(), trash_service.clone()).await + Self::cleanup_expired_items(trash_repository.clone(), trash_service.clone()) + .await .unwrap_or_else(|e| error!("Error in initial trash cleanup: {:?}", e)); - + loop { interval.tick().await; debug!("Running scheduled trash cleanup task"); - - if let Err(e) = Self::cleanup_expired_items( - trash_repository.clone(), - trash_service.clone() - ).await { + + if let Err(e) = + Self::cleanup_expired_items(trash_repository.clone(), trash_service.clone()) + .await + { error!("Error in scheduled trash cleanup: {:?}", e); } } }); } - + /// Cleans up expired items in the trash #[instrument(skip(trash_repository, trash_service))] async fn cleanup_expired_items( @@ -65,24 +69,24 @@ impl TrashCleanupService { trash_service: Arc, ) -> Result<()> { debug!("Starting cleanup of expired items in the trash"); - + // Get all expired items let expired_items = trash_repository.get_expired_items().await?; - + if expired_items.is_empty() { debug!("No expired items to clean up"); return Ok(()); } - + info!("Found {} expired items to delete", expired_items.len()); - + // Delete each expired item for item in expired_items { let trash_id = item.id().to_string(); let user_id = item.user_id().to_string(); - + debug!("Deleting expired item: id={}, user={}", trash_id, user_id); - + // If a deletion fails, continue with the rest if let Err(e) = trash_service.delete_permanently(&trash_id, &user_id).await { error!("Error deleting expired item {}: {:?}", trash_id, e); @@ -90,8 +94,8 @@ impl TrashCleanupService { debug!("Expired item deleted successfully: {}", trash_id); } } - + info!("Trash cleanup completed"); Ok(()) } -} \ No newline at end of file +} diff --git a/src/infrastructure/services/write_behind_cache.rs b/src/infrastructure/services/write_behind_cache.rs index 40afc1e8..f1c8751d 100644 --- a/src/infrastructure/services/write_behind_cache.rs +++ b/src/infrastructure/services/write_behind_cache.rs @@ -1,483 +1,501 @@ -// ═══════════════════════════════════════════════════════════════════════════════ -// WRITE-BEHIND CACHE - Zero-latency uploads for small files -// ═══════════════════════════════════════════════════════════════════════════════ -// -// Strategy: -// 1. For files < 1MB, store in RAM and respond immediately (201 Created) -// 2. Flush to disk asynchronously in background -// 3. Serve reads from cache while pending flush -// 4. On read miss, check if pending then serve from cache -// -// This gives users perceived ~0ms upload latency for small files -// ═══════════════════════════════════════════════════════════════════════════════ - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::{RwLock, mpsc}; -use tokio::fs; -use tokio::io::AsyncWriteExt; -use bytes::Bytes; -use async_trait::async_trait; - -use crate::application::ports::cache_ports::{WriteBehindCachePort, WriteBehindStatsDto}; -use crate::domain::errors::DomainError; - -/// Maximum size for write-behind cache (files larger bypass cache) -const WRITE_BEHIND_MAX_SIZE: usize = 1024 * 1024; // 1MB - -/// Maximum total cache size in bytes -const MAX_CACHE_SIZE: usize = 100 * 1024 * 1024; // 100MB total - -/// Maximum time a file can stay pending before forced flush -const MAX_PENDING_DURATION: Duration = Duration::from_secs(30); - -/// Flush check interval -const FLUSH_INTERVAL: Duration = Duration::from_millis(100); - -/// Entry in the write-behind cache -#[derive(Clone)] -pub struct PendingWrite { - /// File content - pub content: Bytes, - /// Target path on disk - pub target_path: PathBuf, - /// When this entry was created - pub created_at: Instant, - /// File ID for tracking - pub file_id: String, -} - -/// Statistics for monitoring -#[derive(Debug, Clone, Default)] -pub struct WriteBehindStats { - pub pending_count: usize, - pub pending_bytes: usize, - pub total_writes: u64, - pub total_bytes_written: u64, - pub cache_hits: u64, - pub avg_flush_time_us: u64, -} - -/// Write-Behind Cache for zero-latency small file uploads -pub struct WriteBehindCache { - /// Pending writes indexed by file ID - pending: Arc>>, - /// Current total size of pending data - current_size: Arc>, - /// Channel to signal flush worker - flush_tx: mpsc::Sender, - /// Statistics - stats: Arc>, -} - -/// Commands for the flush worker -enum FlushCommand { - /// Flush a specific file - FlushFile(String), - /// Flush all pending files - FlushAll, - /// Shutdown the worker - Shutdown, -} - -impl WriteBehindCache { - /// Create a new write-behind cache with background flush worker - pub fn new() -> Arc { - let (flush_tx, flush_rx) = mpsc::channel(1000); - - let cache = Arc::new(Self { - pending: Arc::new(RwLock::new(HashMap::new())), - current_size: Arc::new(RwLock::new(0)), - flush_tx, - stats: Arc::new(RwLock::new(WriteBehindStats::default())), - }); - - // Start the background flush worker - let cache_clone = cache.clone(); - tokio::spawn(async move { - cache_clone.flush_worker(flush_rx).await; - }); - - // Start the periodic flush checker - let cache_clone2 = cache.clone(); - tokio::spawn(async move { - cache_clone2.periodic_flush_checker().await; - }); - - tracing::info!("⚡ Write-Behind Cache initialized (max {}MB)", MAX_CACHE_SIZE / (1024 * 1024)); - - cache - } - - /// Check if a file size is eligible for write-behind caching - #[inline] - pub fn is_eligible(size: usize) -> bool { - size <= WRITE_BEHIND_MAX_SIZE - } - - /// Put a file in the pending write cache - /// Returns Ok(true) if cached, Ok(false) if cache is full - pub async fn put_pending( - &self, - file_id: String, - content: Bytes, - target_path: PathBuf, - ) -> Result { - let content_size = content.len(); - - // Check if we have space - { - let current = *self.current_size.read().await; - if current + content_size > MAX_CACHE_SIZE { - tracing::debug!( - "Write-behind cache full ({}/{}MB), bypassing for {}", - current / (1024 * 1024), - MAX_CACHE_SIZE / (1024 * 1024), - file_id - ); - return Ok(false); - } - } - - // Add to pending - let entry = PendingWrite { - content, - target_path, - created_at: Instant::now(), - file_id: file_id.clone(), - }; - - { - let mut pending = self.pending.write().await; - let mut size = self.current_size.write().await; - - // If replacing existing entry, adjust size - if let Some(old) = pending.insert(file_id.clone(), entry) { - *size -= old.content.len(); - } - *size += content_size; - } - - // Update stats - { - let mut stats = self.stats.write().await; - stats.pending_count += 1; - stats.pending_bytes += content_size; - } - - // Signal flush worker (non-blocking) - let _ = self.flush_tx.try_send(FlushCommand::FlushFile(file_id.clone())); - - tracing::debug!("⚡ Cached pending write: {} ({} bytes)", file_id, content_size); - - Ok(true) - } - - /// Get content from cache if pending (for reads before flush completes) - pub async fn get_pending(&self, file_id: &str) -> Option { - let pending = self.pending.read().await; - if let Some(entry) = pending.get(file_id) { - // Update cache hit stats - let mut stats = self.stats.write().await; - stats.cache_hits += 1; - - tracing::debug!("⚡ Cache hit for pending file: {}", file_id); - return Some(entry.content.clone()); - } - None - } - - /// Check if a file is pending flush - pub async fn is_pending(&self, file_id: &str) -> bool { - self.pending.read().await.contains_key(file_id) - } - - /// Force immediate flush of a specific file (for critical operations) - pub async fn force_flush(&self, file_id: &str) -> Result<(), std::io::Error> { - let entry = { - let pending = self.pending.read().await; - pending.get(file_id).cloned() - }; - - if let Some(entry) = entry { - self.flush_single(&entry.file_id, &entry).await?; - } - - Ok(()) - } - - /// Flush all pending writes immediately - pub async fn flush_all(&self) -> Result<(), std::io::Error> { - let _ = self.flush_tx.send(FlushCommand::FlushAll).await; - - // Wait a bit for flush to complete - tokio::time::sleep(Duration::from_millis(50)).await; - - Ok(()) - } - - /// Gracefully shutdown the write-behind cache - /// Flushes all pending writes before stopping the background worker - pub async fn shutdown(&self) -> Result<(), std::io::Error> { - tracing::info!("🛑 Shutting down write-behind cache..."); - - // First flush all pending writes - self.flush_all().await?; - - // Then signal the worker to stop - let _ = self.flush_tx.send(FlushCommand::Shutdown).await; - - // Give worker time to process shutdown - tokio::time::sleep(Duration::from_millis(100)).await; - - tracing::info!("✅ Write-behind cache shutdown complete"); - Ok(()) - } - - /// Get current statistics - pub async fn get_stats(&self) -> WriteBehindStats { - self.stats.read().await.clone() - } - - /// Background worker that handles actual disk writes - async fn flush_worker(&self, mut rx: mpsc::Receiver) { - tracing::info!("🔄 Write-behind flush worker started"); - - while let Some(cmd) = rx.recv().await { - match cmd { - FlushCommand::FlushFile(file_id) => { - // Small delay to batch nearby writes - tokio::time::sleep(Duration::from_millis(10)).await; - - let entry = { - let pending = self.pending.read().await; - pending.get(&file_id).cloned() - }; - - if let Some(entry) = entry - && let Err(e) = self.flush_single(&file_id, &entry).await { - tracing::error!("Failed to flush {}: {}", file_id, e); - // Keep in cache for retry - continue; - } - } - FlushCommand::FlushAll => { - let entries: Vec<_> = { - let pending = self.pending.read().await; - pending.iter().map(|(k, v)| (k.clone(), v.clone())).collect() - }; - - for (file_id, entry) in entries { - if let Err(e) = self.flush_single(&file_id, &entry).await { - tracing::error!("Failed to flush {}: {}", file_id, e); - } - } - } - FlushCommand::Shutdown => { - tracing::info!("Write-behind flush worker shutting down"); - break; - } - } - } - } - - /// Flush a single file to disk - async fn flush_single(&self, file_id: &str, entry: &PendingWrite) -> Result<(), std::io::Error> { - let start = Instant::now(); - - // Ensure parent directory exists - if let Some(parent) = entry.target_path.parent() { - fs::create_dir_all(parent).await?; - } - - // Write atomically using temp file + rename - let temp_path = entry.target_path.with_extension("tmp"); - - { - let mut file = fs::File::create(&temp_path).await?; - file.write_all(&entry.content).await?; - file.sync_all().await?; - } - - fs::rename(&temp_path, &entry.target_path).await?; - - let elapsed = start.elapsed(); - let content_len = entry.content.len(); - - // Remove from pending - { - let mut pending = self.pending.write().await; - let mut size = self.current_size.write().await; - - if pending.remove(file_id).is_some() { - *size = size.saturating_sub(content_len); - } - } - - // Update stats - { - let mut stats = self.stats.write().await; - stats.pending_count = stats.pending_count.saturating_sub(1); - stats.pending_bytes = stats.pending_bytes.saturating_sub(content_len); - stats.total_writes += 1; - stats.total_bytes_written += content_len as u64; - - // Running average of flush time - let flush_us = elapsed.as_micros() as u64; - if stats.avg_flush_time_us == 0 { - stats.avg_flush_time_us = flush_us; - } else { - stats.avg_flush_time_us = (stats.avg_flush_time_us * 9 + flush_us) / 10; - } - } - - tracing::debug!( - "💾 Flushed {} to disk ({} bytes in {:?})", - file_id, - content_len, - elapsed - ); - - Ok(()) - } - - /// Periodic checker for stale pending writes - async fn periodic_flush_checker(&self) { - let mut interval = tokio::time::interval(FLUSH_INTERVAL); - - loop { - interval.tick().await; - - let stale_files: Vec = { - let pending = self.pending.read().await; - pending - .iter() - .filter(|(_, entry)| entry.created_at.elapsed() > MAX_PENDING_DURATION) - .map(|(id, _)| id.clone()) - .collect() - }; - - for file_id in stale_files { - tracing::warn!("Forcing flush of stale pending file: {}", file_id); - let _ = self.flush_tx.try_send(FlushCommand::FlushFile(file_id)); - } - } - } -} - -// ─── Port implementation ───────────────────────────────────────────────────── - -#[async_trait] -impl WriteBehindCachePort for WriteBehindCache { - fn is_eligible_size(&self, size: usize) -> bool { - WriteBehindCache::is_eligible(size) - } - - async fn put_pending( - &self, - file_id: String, - content: Bytes, - target_path: PathBuf, - ) -> Result { - self.put_pending(file_id, content, target_path).await.map_err(DomainError::from) - } - - async fn get_pending(&self, file_id: &str) -> Option { - self.get_pending(file_id).await - } - - async fn is_pending(&self, file_id: &str) -> bool { - self.is_pending(file_id).await - } - - async fn force_flush(&self, file_id: &str) -> Result<(), DomainError> { - self.force_flush(file_id).await.map_err(DomainError::from) - } - - async fn flush_all(&self) -> Result<(), DomainError> { - self.flush_all().await.map_err(DomainError::from) - } - - async fn shutdown(&self) -> Result<(), DomainError> { - self.shutdown().await.map_err(DomainError::from) - } - - async fn get_stats(&self) -> WriteBehindStatsDto { - let stats = self.get_stats().await; - WriteBehindStatsDto { - pending_count: stats.pending_count, - pending_bytes: stats.pending_bytes, - total_writes: stats.total_writes, - total_bytes_written: stats.total_bytes_written, - cache_hits: stats.cache_hits, - avg_flush_time_us: stats.avg_flush_time_us, - } - } -} - -impl Default for WriteBehindCache { - fn default() -> Self { - // Note: This creates a non-Arc version, prefer using new() - let (flush_tx, _) = mpsc::channel(1); - Self { - pending: Arc::new(RwLock::new(HashMap::new())), - current_size: Arc::new(RwLock::new(0)), - flush_tx, - stats: Arc::new(RwLock::new(WriteBehindStats::default())), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_write_behind_basic() { - let cache = WriteBehindCache::new(); - let temp_dir = TempDir::new().unwrap(); - let target = temp_dir.path().join("test.txt"); - - let content = Bytes::from("Hello, World!"); - - // Put in cache - let cached = cache.put_pending( - "test-id".to_string(), - content.clone(), - target.clone(), - ).await.unwrap(); - - assert!(cached); - assert!(cache.is_pending("test-id").await); - - // Should be readable from cache - let cached_content = cache.get_pending("test-id").await.unwrap(); - assert_eq!(cached_content, content); - - // Force flush - cache.force_flush("test-id").await.unwrap(); - - // Should no longer be pending - assert!(!cache.is_pending("test-id").await); - - // File should exist on disk - assert!(target.exists()); - let disk_content = std::fs::read(&target).unwrap(); - assert_eq!(disk_content, content.as_ref()); - } - - #[tokio::test] - async fn test_eligibility() { - // 500KB should be eligible - assert!(WriteBehindCache::is_eligible(500 * 1024)); - - // 1MB exactly should be eligible - assert!(WriteBehindCache::is_eligible(1024 * 1024)); - - // Over 1MB should not be eligible - assert!(!WriteBehindCache::is_eligible(1024 * 1024 + 1)); - } -} +// ═══════════════════════════════════════════════════════════════════════════════ +// WRITE-BEHIND CACHE - Zero-latency uploads for small files +// ═══════════════════════════════════════════════════════════════════════════════ +// +// Strategy: +// 1. For files < 1MB, store in RAM and respond immediately (201 Created) +// 2. Flush to disk asynchronously in background +// 3. Serve reads from cache while pending flush +// 4. On read miss, check if pending then serve from cache +// +// This gives users perceived ~0ms upload latency for small files +// ═══════════════════════════════════════════════════════════════════════════════ + +use async_trait::async_trait; +use bytes::Bytes; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tokio::sync::{RwLock, mpsc}; + +use crate::application::ports::cache_ports::{WriteBehindCachePort, WriteBehindStatsDto}; +use crate::domain::errors::DomainError; + +/// Maximum size for write-behind cache (files larger bypass cache) +const WRITE_BEHIND_MAX_SIZE: usize = 1024 * 1024; // 1MB + +/// Maximum total cache size in bytes +const MAX_CACHE_SIZE: usize = 100 * 1024 * 1024; // 100MB total + +/// Maximum time a file can stay pending before forced flush +const MAX_PENDING_DURATION: Duration = Duration::from_secs(30); + +/// Flush check interval +const FLUSH_INTERVAL: Duration = Duration::from_millis(100); + +/// Entry in the write-behind cache +#[derive(Clone)] +pub struct PendingWrite { + /// File content + pub content: Bytes, + /// Target path on disk + pub target_path: PathBuf, + /// When this entry was created + pub created_at: Instant, + /// File ID for tracking + pub file_id: String, +} + +/// Statistics for monitoring +#[derive(Debug, Clone, Default)] +pub struct WriteBehindStats { + pub pending_count: usize, + pub pending_bytes: usize, + pub total_writes: u64, + pub total_bytes_written: u64, + pub cache_hits: u64, + pub avg_flush_time_us: u64, +} + +/// Write-Behind Cache for zero-latency small file uploads +pub struct WriteBehindCache { + /// Pending writes indexed by file ID + pending: Arc>>, + /// Current total size of pending data + current_size: Arc>, + /// Channel to signal flush worker + flush_tx: mpsc::Sender, + /// Statistics + stats: Arc>, +} + +/// Commands for the flush worker +enum FlushCommand { + /// Flush a specific file + FlushFile(String), + /// Flush all pending files + FlushAll, + /// Shutdown the worker + Shutdown, +} + +impl WriteBehindCache { + /// Create a new write-behind cache with background flush worker + pub fn new() -> Arc { + let (flush_tx, flush_rx) = mpsc::channel(1000); + + let cache = Arc::new(Self { + pending: Arc::new(RwLock::new(HashMap::new())), + current_size: Arc::new(RwLock::new(0)), + flush_tx, + stats: Arc::new(RwLock::new(WriteBehindStats::default())), + }); + + // Start the background flush worker + let cache_clone = cache.clone(); + tokio::spawn(async move { + cache_clone.flush_worker(flush_rx).await; + }); + + // Start the periodic flush checker + let cache_clone2 = cache.clone(); + tokio::spawn(async move { + cache_clone2.periodic_flush_checker().await; + }); + + tracing::info!( + "⚡ Write-Behind Cache initialized (max {}MB)", + MAX_CACHE_SIZE / (1024 * 1024) + ); + + cache + } + + /// Check if a file size is eligible for write-behind caching + #[inline] + pub fn is_eligible(size: usize) -> bool { + size <= WRITE_BEHIND_MAX_SIZE + } + + /// Put a file in the pending write cache + /// Returns Ok(true) if cached, Ok(false) if cache is full + pub async fn put_pending( + &self, + file_id: String, + content: Bytes, + target_path: PathBuf, + ) -> Result { + let content_size = content.len(); + + // Check if we have space + { + let current = *self.current_size.read().await; + if current + content_size > MAX_CACHE_SIZE { + tracing::debug!( + "Write-behind cache full ({}/{}MB), bypassing for {}", + current / (1024 * 1024), + MAX_CACHE_SIZE / (1024 * 1024), + file_id + ); + return Ok(false); + } + } + + // Add to pending + let entry = PendingWrite { + content, + target_path, + created_at: Instant::now(), + file_id: file_id.clone(), + }; + + { + let mut pending = self.pending.write().await; + let mut size = self.current_size.write().await; + + // If replacing existing entry, adjust size + if let Some(old) = pending.insert(file_id.clone(), entry) { + *size -= old.content.len(); + } + *size += content_size; + } + + // Update stats + { + let mut stats = self.stats.write().await; + stats.pending_count += 1; + stats.pending_bytes += content_size; + } + + // Signal flush worker (non-blocking) + let _ = self + .flush_tx + .try_send(FlushCommand::FlushFile(file_id.clone())); + + tracing::debug!( + "⚡ Cached pending write: {} ({} bytes)", + file_id, + content_size + ); + + Ok(true) + } + + /// Get content from cache if pending (for reads before flush completes) + pub async fn get_pending(&self, file_id: &str) -> Option { + let pending = self.pending.read().await; + if let Some(entry) = pending.get(file_id) { + // Update cache hit stats + let mut stats = self.stats.write().await; + stats.cache_hits += 1; + + tracing::debug!("⚡ Cache hit for pending file: {}", file_id); + return Some(entry.content.clone()); + } + None + } + + /// Check if a file is pending flush + pub async fn is_pending(&self, file_id: &str) -> bool { + self.pending.read().await.contains_key(file_id) + } + + /// Force immediate flush of a specific file (for critical operations) + pub async fn force_flush(&self, file_id: &str) -> Result<(), std::io::Error> { + let entry = { + let pending = self.pending.read().await; + pending.get(file_id).cloned() + }; + + if let Some(entry) = entry { + self.flush_single(&entry.file_id, &entry).await?; + } + + Ok(()) + } + + /// Flush all pending writes immediately + pub async fn flush_all(&self) -> Result<(), std::io::Error> { + let _ = self.flush_tx.send(FlushCommand::FlushAll).await; + + // Wait a bit for flush to complete + tokio::time::sleep(Duration::from_millis(50)).await; + + Ok(()) + } + + /// Gracefully shutdown the write-behind cache + /// Flushes all pending writes before stopping the background worker + pub async fn shutdown(&self) -> Result<(), std::io::Error> { + tracing::info!("🛑 Shutting down write-behind cache..."); + + // First flush all pending writes + self.flush_all().await?; + + // Then signal the worker to stop + let _ = self.flush_tx.send(FlushCommand::Shutdown).await; + + // Give worker time to process shutdown + tokio::time::sleep(Duration::from_millis(100)).await; + + tracing::info!("✅ Write-behind cache shutdown complete"); + Ok(()) + } + + /// Get current statistics + pub async fn get_stats(&self) -> WriteBehindStats { + self.stats.read().await.clone() + } + + /// Background worker that handles actual disk writes + async fn flush_worker(&self, mut rx: mpsc::Receiver) { + tracing::info!("🔄 Write-behind flush worker started"); + + while let Some(cmd) = rx.recv().await { + match cmd { + FlushCommand::FlushFile(file_id) => { + // Small delay to batch nearby writes + tokio::time::sleep(Duration::from_millis(10)).await; + + let entry = { + let pending = self.pending.read().await; + pending.get(&file_id).cloned() + }; + + if let Some(entry) = entry + && let Err(e) = self.flush_single(&file_id, &entry).await + { + tracing::error!("Failed to flush {}: {}", file_id, e); + // Keep in cache for retry + continue; + } + } + FlushCommand::FlushAll => { + let entries: Vec<_> = { + let pending = self.pending.read().await; + pending + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }; + + for (file_id, entry) in entries { + if let Err(e) = self.flush_single(&file_id, &entry).await { + tracing::error!("Failed to flush {}: {}", file_id, e); + } + } + } + FlushCommand::Shutdown => { + tracing::info!("Write-behind flush worker shutting down"); + break; + } + } + } + } + + /// Flush a single file to disk + async fn flush_single( + &self, + file_id: &str, + entry: &PendingWrite, + ) -> Result<(), std::io::Error> { + let start = Instant::now(); + + // Ensure parent directory exists + if let Some(parent) = entry.target_path.parent() { + fs::create_dir_all(parent).await?; + } + + // Write atomically using temp file + rename + let temp_path = entry.target_path.with_extension("tmp"); + + { + let mut file = fs::File::create(&temp_path).await?; + file.write_all(&entry.content).await?; + file.sync_all().await?; + } + + fs::rename(&temp_path, &entry.target_path).await?; + + let elapsed = start.elapsed(); + let content_len = entry.content.len(); + + // Remove from pending + { + let mut pending = self.pending.write().await; + let mut size = self.current_size.write().await; + + if pending.remove(file_id).is_some() { + *size = size.saturating_sub(content_len); + } + } + + // Update stats + { + let mut stats = self.stats.write().await; + stats.pending_count = stats.pending_count.saturating_sub(1); + stats.pending_bytes = stats.pending_bytes.saturating_sub(content_len); + stats.total_writes += 1; + stats.total_bytes_written += content_len as u64; + + // Running average of flush time + let flush_us = elapsed.as_micros() as u64; + if stats.avg_flush_time_us == 0 { + stats.avg_flush_time_us = flush_us; + } else { + stats.avg_flush_time_us = (stats.avg_flush_time_us * 9 + flush_us) / 10; + } + } + + tracing::debug!( + "💾 Flushed {} to disk ({} bytes in {:?})", + file_id, + content_len, + elapsed + ); + + Ok(()) + } + + /// Periodic checker for stale pending writes + async fn periodic_flush_checker(&self) { + let mut interval = tokio::time::interval(FLUSH_INTERVAL); + + loop { + interval.tick().await; + + let stale_files: Vec = { + let pending = self.pending.read().await; + pending + .iter() + .filter(|(_, entry)| entry.created_at.elapsed() > MAX_PENDING_DURATION) + .map(|(id, _)| id.clone()) + .collect() + }; + + for file_id in stale_files { + tracing::warn!("Forcing flush of stale pending file: {}", file_id); + let _ = self.flush_tx.try_send(FlushCommand::FlushFile(file_id)); + } + } + } +} + +// ─── Port implementation ───────────────────────────────────────────────────── + +#[async_trait] +impl WriteBehindCachePort for WriteBehindCache { + fn is_eligible_size(&self, size: usize) -> bool { + WriteBehindCache::is_eligible(size) + } + + async fn put_pending( + &self, + file_id: String, + content: Bytes, + target_path: PathBuf, + ) -> Result { + self.put_pending(file_id, content, target_path) + .await + .map_err(DomainError::from) + } + + async fn get_pending(&self, file_id: &str) -> Option { + self.get_pending(file_id).await + } + + async fn is_pending(&self, file_id: &str) -> bool { + self.is_pending(file_id).await + } + + async fn force_flush(&self, file_id: &str) -> Result<(), DomainError> { + self.force_flush(file_id).await.map_err(DomainError::from) + } + + async fn flush_all(&self) -> Result<(), DomainError> { + self.flush_all().await.map_err(DomainError::from) + } + + async fn shutdown(&self) -> Result<(), DomainError> { + self.shutdown().await.map_err(DomainError::from) + } + + async fn get_stats(&self) -> WriteBehindStatsDto { + let stats = self.get_stats().await; + WriteBehindStatsDto { + pending_count: stats.pending_count, + pending_bytes: stats.pending_bytes, + total_writes: stats.total_writes, + total_bytes_written: stats.total_bytes_written, + cache_hits: stats.cache_hits, + avg_flush_time_us: stats.avg_flush_time_us, + } + } +} + +impl Default for WriteBehindCache { + fn default() -> Self { + // Note: This creates a non-Arc version, prefer using new() + let (flush_tx, _) = mpsc::channel(1); + Self { + pending: Arc::new(RwLock::new(HashMap::new())), + current_size: Arc::new(RwLock::new(0)), + flush_tx, + stats: Arc::new(RwLock::new(WriteBehindStats::default())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_write_behind_basic() { + let cache = WriteBehindCache::new(); + let temp_dir = TempDir::new().unwrap(); + let target = temp_dir.path().join("test.txt"); + + let content = Bytes::from("Hello, World!"); + + // Put in cache + let cached = cache + .put_pending("test-id".to_string(), content.clone(), target.clone()) + .await + .unwrap(); + + assert!(cached); + assert!(cache.is_pending("test-id").await); + + // Should be readable from cache + let cached_content = cache.get_pending("test-id").await.unwrap(); + assert_eq!(cached_content, content); + + // Force flush + cache.force_flush("test-id").await.unwrap(); + + // Should no longer be pending + assert!(!cache.is_pending("test-id").await); + + // File should exist on disk + assert!(target.exists()); + let disk_content = std::fs::read(&target).unwrap(); + assert_eq!(disk_content, content.as_ref()); + } + + #[tokio::test] + async fn test_eligibility() { + // 500KB should be eligible + assert!(WriteBehindCache::is_eligible(500 * 1024)); + + // 1MB exactly should be eligible + assert!(WriteBehindCache::is_eligible(1024 * 1024)); + + // Over 1MB should not be eligible + assert!(!WriteBehindCache::is_eligible(1024 * 1024 + 1)); + } +} diff --git a/src/infrastructure/services/zip_service.rs b/src/infrastructure/services/zip_service.rs index 9a243283..8aebc701 100644 --- a/src/infrastructure/services/zip_service.rs +++ b/src/infrastructure/services/zip_service.rs @@ -1,33 +1,33 @@ -use std::io::{Cursor, Read, Write}; -use zip::{ZipWriter, write::SimpleFileOptions}; -use thiserror::Error; -use tracing::*; -use async_trait::async_trait; use crate::{ application::dtos::file_dto::FileDto, application::dtos::folder_dto::FolderDto, - application::ports::inbound::FolderUseCase, application::ports::file_ports::FileRetrievalUseCase, + application::ports::inbound::FolderUseCase, application::ports::zip_ports::ZipPort, - common::errors::{Result, DomainError, ErrorKind}, + common::errors::{DomainError, ErrorKind, Result}, }; +use async_trait::async_trait; +use std::io::{Cursor, Read, Write}; use std::sync::Arc; +use thiserror::Error; +use tracing::*; +use zip::{ZipWriter, write::SimpleFileOptions}; /// Error related to ZIP file creation #[derive(Debug, Error)] pub enum ZipError { #[error("IO error: {0}")] IoError(#[from] std::io::Error), - + #[error("ZIP error: {0}")] ZipError(#[from] zip::result::ZipError), - + #[error("Error reading file: {0}")] FileReadError(String), - + #[error("Error getting folder contents: {0}")] FolderContentsError(String), - + #[error("Folder not found: {0}")] FolderNotFound(String), } @@ -54,18 +54,24 @@ pub struct ZipService { impl ZipService { /// Creates a new instance of the ZIP service with a reference to the file service - pub fn new(file_service: Arc, folder_service: Arc) -> Self { + pub fn new( + file_service: Arc, + folder_service: Arc, + ) -> Self { Self { file_service, folder_service, } } - + /// Creates a ZIP file with the contents of a folder and all its subfolders /// Returns the ZIP bytes pub async fn create_folder_zip(&self, folder_id: &str, folder_name: &str) -> Result> { - info!("Creating ZIP for folder: {} (ID: {})", folder_name, folder_id); - + info!( + "Creating ZIP for folder: {} (ID: {})", + folder_name, folder_id + ); + // Verify if the folder exists let folder = match self.folder_service.get_folder(folder_id).await { Ok(folder) => folder, @@ -74,31 +80,32 @@ impl ZipService { return Err(ZipError::FolderNotFound(folder_id.to_string()).into()); } }; - + // Create an in-memory buffer for the ZIP let buf = Cursor::new(Vec::new()); let mut zip = ZipWriter::new(buf); - + // Set compression options let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .unix_permissions(0o755); - + // Object to track processed folders and avoid cycles let mut processed_folders = std::collections::HashSet::new(); - + // Process the root folder and build the ZIP self.process_folder_recursively( &mut zip, &folder, folder_name, &options, - &mut processed_folders - ).await?; - + &mut processed_folders, + ) + .await?; + // Finalize the ZIP and get the bytes let mut zip_buf = zip.finish()?; - + let mut bytes = Vec::new(); match zip_buf.read_to_end(&mut bytes) { Ok(_) => Ok(bytes), @@ -108,7 +115,7 @@ impl ZipService { } } } - + // Alternative implementation to avoid recursion in async async fn process_folder_recursively( &self, @@ -116,31 +123,31 @@ impl ZipService { folder: &FolderDto, path: &str, options: &SimpleFileOptions, - processed_folders: &mut std::collections::HashSet + processed_folders: &mut std::collections::HashSet, ) -> Result<()> { // Structure to represent pending work struct PendingFolder { folder: FolderDto, path: String, } - + // Work queue for iterative processing let mut work_queue = vec![PendingFolder { folder: folder.clone(), path: path.to_string(), }]; - + // Process the queue while there are elements while let Some(current) = work_queue.pop() { let folder_id = current.folder.id.to_string(); - + // Avoid cycles if processed_folders.contains(&folder_id) { continue; } - + processed_folders.insert(folder_id.clone()); - + // Create the directory entry in the ZIP let folder_path = format!("{}/", current.path); match zip.add_directory(&folder_path, *options) { @@ -150,30 +157,39 @@ impl ZipService { // Continue even if creating the directory fails (it could be a duplicate) } } - + // Add files from the folder to the ZIP let files = match self.file_service.list_files(Some(&folder_id)).await { Ok(files) => files, Err(e) => { error!("Error listing files in folder {}: {}", folder_id, e); - return Err(ZipError::FolderContentsError(format!("Error listing files: {}", e)).into()); + return Err(ZipError::FolderContentsError(format!( + "Error listing files: {}", + e + )) + .into()); } }; - + // Add each file to the ZIP for file in files { - self.add_file_to_zip(zip, &file, &folder_path, options).await?; + self.add_file_to_zip(zip, &file, &folder_path, options) + .await?; } - + // Process subfolders let subfolders = match self.folder_service.list_folders(Some(&folder_id)).await { Ok(folders) => folders, Err(e) => { error!("Error listing subfolders in {}: {}", folder_id, e); - return Err(ZipError::FolderContentsError(format!("Error listing subfolders: {}", e)).into()); + return Err(ZipError::FolderContentsError(format!( + "Error listing subfolders: {}", + e + )) + .into()); } }; - + // Add subfolders to the queue for subfolder in subfolders { let subfolder_path = format!("{}/{}", current.path, subfolder.name); @@ -183,10 +199,10 @@ impl ZipService { }); } } - + Ok(()) } - + // Adds a file to the ZIP async fn add_file_to_zip( &self, @@ -197,29 +213,31 @@ impl ZipService { ) -> Result<()> { let file_path = format!("{}{}", folder_path, file.name); info!("Adding file to ZIP: {}", file_path); - + // Get the file content let file_id = file.id.to_string(); let content = match self.file_service.get_file_content(&file_id).await { Ok(content) => content, Err(e) => { error!("Error reading file content {}: {}", file_id, e); - return Err(ZipError::FileReadError(format!("Error reading file {}: {}", file_id, e)).into()); + return Err(ZipError::FileReadError(format!( + "Error reading file {}: {}", + file_id, e + )) + .into()); } }; - + // Write file to the ZIP match zip.start_file_from_path(std::path::Path::new(&file_path), *options) { - Ok(_) => { - match zip.write_all(&content) { - Ok(_) => { - debug!("File added to ZIP: {}", file_path); - Ok(()) - }, - Err(e) => { - error!("Error writing file content {}: {}", file_path, e); - Err(ZipError::IoError(e).into()) - } + Ok(_) => match zip.write_all(&content) { + Ok(_) => { + debug!("File added to ZIP: {}", file_path); + Ok(()) + } + Err(e) => { + error!("Error writing file content {}: {}", file_path, e); + Err(ZipError::IoError(e).into()) } }, Err(e) => { @@ -241,4 +259,4 @@ impl ZipPort for ZipService { ) -> std::result::Result, DomainError> { self.create_folder_zip(folder_id, folder_name).await } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index c86cf1a5..375d1206 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1,464 +1,564 @@ -use axum::{ - Router, - routing::{get, put, post, delete}, - extract::{State, Json, Path, Query}, - http::{StatusCode, HeaderMap, header}, - response::IntoResponse, -}; - -use crate::common::di::AppState; -use crate::application::dtos::settings_dto::{ - SaveOidcSettingsDto, TestOidcConnectionDto, - UpdateUserRoleDto, UpdateUserActiveDto, UpdateUserQuotaDto, - ListUsersQueryDto, DashboardStatsDto, - AdminCreateUserDto, AdminResetPasswordDto, -}; -use crate::interfaces::errors::AppError; - -/// Admin API routes — all require admin role. -pub fn admin_routes() -> Router { - Router::new() - // OIDC settings - .route("/settings/oidc", get(get_oidc_settings)) - .route("/settings/oidc", put(save_oidc_settings)) - .route("/settings/oidc/test", post(test_oidc_connection)) - .route("/settings/general", get(get_general_settings)) - // Dashboard / stats - .route("/dashboard", get(get_dashboard_stats)) - // User management - .route("/users", get(list_users)) - .route("/users", post(create_user)) - .route("/users/{id}", get(get_user)) - .route("/users/{id}", delete(delete_user)) - .route("/users/{id}/role", put(update_user_role)) - .route("/users/{id}/active", put(update_user_active)) - .route("/users/{id}/quota", put(update_user_quota)) - .route("/users/{id}/password", put(reset_user_password)) - // Registration control - .route("/settings/registration", get(get_registration_setting)) - .route("/settings/registration", put(set_registration_setting)) -} - -/// Validate JWT and require admin role. Returns (user_id, role). -async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, String), AppError> { - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; - - let claims = auth.token_service.validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - if claims.role != "admin" { - return Err(AppError::new( - StatusCode::FORBIDDEN, - "Admin access required", - "Forbidden", - )); - } - - Ok((claims.sub, claims.role)) -} - -/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel -async fn get_oidc_settings( - State(state): State, - headers: HeaderMap, -) -> Result { - admin_guard(&state, &headers).await?; - - let svc = state.admin_settings_service.as_ref() - .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; - - let settings = svc.get_oidc_settings().await - .map_err(|e| AppError::internal_error(format!("Failed to load settings: {}", e)))?; - - Ok(Json(settings)) -} - -/// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload -async fn save_oidc_settings( - State(state): State, - headers: HeaderMap, - Json(dto): Json, -) -> Result { - let (user_id, _) = admin_guard(&state, &headers).await?; - - let svc = state.admin_settings_service.as_ref() - .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; - - svc.save_oidc_settings(dto, &user_id).await - .map_err(|e| AppError::internal_error(format!("Failed to save settings: {}", e)))?; - - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": "OIDC settings saved and applied successfully" - })))) -} - -/// POST /api/admin/settings/oidc/test — test OIDC discovery -async fn test_oidc_connection( - State(state): State, - headers: HeaderMap, - Json(dto): Json, -) -> Result { - admin_guard(&state, &headers).await?; - - let svc = state.admin_settings_service.as_ref() - .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; - - let result = svc.test_oidc_connection(dto).await - .map_err(|e| AppError::internal_error(format!("Connection test failed: {}", e)))?; - - Ok(Json(result)) -} - -/// GET /api/admin/settings/general — system overview (backward compat) -async fn get_general_settings( - State(state): State, - headers: HeaderMap, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let user_count = auth.auth_application_service.count_users_efficient().await.unwrap_or(0); - let oidc_configured = auth.auth_application_service.oidc_enabled(); - - Ok(Json(serde_json::json!({ - "server_version": env!("CARGO_PKG_VERSION"), - "auth_enabled": true, - "total_users": user_count, - "oidc_configured": oidc_configured, - }))) -} - -// ============================================================================ -// Dashboard / Stats -// ============================================================================ - -/// GET /api/admin/dashboard — full dashboard statistics -async fn get_dashboard_stats( - State(state): State, - headers: HeaderMap, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let auth_app = &auth.auth_application_service; - - // Get storage stats from repository (single efficient query) - let db_pool = state.db_pool.as_ref() - .ok_or_else(|| AppError::internal_error("Database not available"))?; - - // Use direct SQL for aggregated stats — more efficient than loading all users - let stats_row = sqlx::query( - r#" - SELECT - COUNT(*)::INT8 as total_users, - COUNT(*) FILTER (WHERE active = true)::INT8 as active_users, - COUNT(*) FILTER (WHERE role::text = 'admin')::INT8 as admin_users, - COALESCE(SUM(storage_quota_bytes)::INT8, 0) as total_quota_bytes, - COALESCE(SUM(storage_used_bytes)::INT8, 0) as total_used_bytes, - COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8)::INT8 as users_over_80, - COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes)::INT8 as users_over_quota - FROM auth.users - "# - ) - .fetch_one(db_pool.as_ref()) - .await - .map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?; - - use sqlx::Row; - let total_quota: i64 = stats_row.get("total_quota_bytes"); - let total_used: i64 = stats_row.get("total_used_bytes"); - let usage_percent = if total_quota > 0 { - (total_used as f64 / total_quota as f64) * 100.0 - } else { - 0.0 - }; - - let stats = DashboardStatsDto { - server_version: env!("CARGO_PKG_VERSION").to_string(), - auth_enabled: true, - oidc_configured: auth_app.oidc_enabled(), - quotas_enabled: true, // Feature flag could be checked here - total_users: stats_row.get("total_users"), - active_users: stats_row.get("active_users"), - admin_users: stats_row.get("admin_users"), - total_quota_bytes: total_quota, - total_used_bytes: total_used, - storage_usage_percent: (usage_percent * 100.0).round() / 100.0, - users_over_80_percent: stats_row.get("users_over_80"), - users_over_quota: stats_row.get("users_over_quota"), - registration_enabled: { - if let Some(svc) = state.admin_settings_service.as_ref() { - svc.get_registration_enabled().await - } else { - true // default: enabled - } - }, - }; - - Ok(Json(stats)) -} - -// ============================================================================ -// User Management -// ============================================================================ - -/// GET /api/admin/users?limit=50&offset=0 — list all users -async fn list_users( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let limit = query.limit.unwrap_or(100).min(500); - let offset = query.offset.unwrap_or(0); - - let users = auth.auth_application_service.list_users(limit, offset).await - .map_err(|e| AppError::internal_error(format!("Failed to list users: {}", e)))?; - - let total = auth.auth_application_service.count_users_efficient().await.unwrap_or(0); - - Ok(Json(serde_json::json!({ - "users": users, - "total": total, - "limit": limit, - "offset": offset, - }))) -} - -/// GET /api/admin/users/:id — get single user -async fn get_user( - State(state): State, - headers: HeaderMap, - Path(id): Path, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let user = auth.auth_application_service.get_user_admin(&id).await - .map_err(|e| AppError::not_found(format!("User not found: {}", e)))?; - - Ok(Json(user)) -} - -/// DELETE /api/admin/users/:id — delete a user -async fn delete_user( - State(state): State, - headers: HeaderMap, - Path(id): Path, -) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; - - // Prevent self-deletion - if admin_id == id { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - "Cannot delete your own account", - "SelfDeletion", - )); - } - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - auth.auth_application_service.delete_user_admin(&id).await - .map_err(|e| AppError::internal_error(format!("Failed to delete user: {}", e)))?; - - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": "User deleted successfully" - })))) -} - -/// PUT /api/admin/users/:id/role — change user role -async fn update_user_role( - State(state): State, - headers: HeaderMap, - Path(id): Path, - Json(dto): Json, -) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; - - // Prevent changing own role - if admin_id == id { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - "Cannot change your own role", - "SelfRoleChange", - )); - } - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - auth.auth_application_service.change_user_role(&id, &dto.role).await - .map_err(|e| AppError::internal_error(format!("Failed to change role: {}", e)))?; - - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": format!("User role updated to '{}'", dto.role) - })))) -} - -/// PUT /api/admin/users/:id/active — activate/deactivate user -async fn update_user_active( - State(state): State, - headers: HeaderMap, - Path(id): Path, - Json(dto): Json, -) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; - - // Prevent deactivating yourself - if admin_id == id && !dto.active { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - "Cannot deactivate your own account", - "SelfDeactivation", - )); - } - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - auth.auth_application_service.set_user_active(&id, dto.active).await - .map_err(|e| AppError::internal_error(format!("Failed to update user status: {}", e)))?; - - let status = if dto.active { "activated" } else { "deactivated" }; - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": format!("User {}", status) - })))) -} - -/// PUT /api/admin/users/:id/quota — update user storage quota -async fn update_user_quota( - State(state): State, - headers: HeaderMap, - Path(id): Path, - Json(dto): Json, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - auth.auth_application_service.update_user_quota(&id, dto.quota_bytes).await - .map_err(|e| AppError::internal_error(format!("Failed to update quota: {}", e)))?; - - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": "User quota updated", - "quota_bytes": dto.quota_bytes, - })))) -} - -// ============================================================================ -// Admin User Creation & Password Reset -// ============================================================================ - -/// POST /api/admin/users — create a new user (admin only) -async fn create_user( - State(state): State, - headers: HeaderMap, - Json(dto): Json, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - let user = auth.auth_application_service.admin_create_user(dto).await - .map_err(|e| AppError::new( - StatusCode::BAD_REQUEST, - format!("Failed to create user: {}", e), - "CreateUserFailed", - ))?; - - Ok((StatusCode::CREATED, Json(user))) -} - -/// PUT /api/admin/users/:id/password — reset a user's password (admin only) -async fn reset_user_password( - State(state): State, - headers: HeaderMap, - Path(id): Path, - Json(dto): Json, -) -> Result { - admin_guard(&state, &headers).await?; - - let auth = state.auth_service.as_ref() - .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - - auth.auth_application_service.admin_reset_password(&id, &dto.new_password).await - .map_err(|e| AppError::new( - StatusCode::BAD_REQUEST, - format!("Failed to reset password: {}", e), - "ResetPasswordFailed", - ))?; - - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": "Password reset successfully" - })))) -} - -// ============================================================================ -// Registration Control -// ============================================================================ - -/// GET /api/admin/settings/registration — check if public registration is enabled -async fn get_registration_setting( - State(state): State, - headers: HeaderMap, -) -> Result { - admin_guard(&state, &headers).await?; - - let svc = state.admin_settings_service.as_ref() - .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; - - let val = svc.get_registration_enabled().await; - - Ok(Json(serde_json::json!({ - "registration_enabled": val, - }))) -} - -/// PUT /api/admin/settings/registration — enable/disable public registration -async fn set_registration_setting( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; - - let enabled = body.get("registration_enabled") - .and_then(|v| v.as_bool()) - .ok_or_else(|| AppError::new( - StatusCode::BAD_REQUEST, - "Missing boolean field 'registration_enabled'", - "InvalidInput", - ))?; - - let svc = state.admin_settings_service.as_ref() - .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; - - svc.set_registration_enabled(enabled, &admin_id).await - .map_err(|e| AppError::internal_error(format!("Failed to save setting: {}", e)))?; - - Ok((StatusCode::OK, Json(serde_json::json!({ - "message": format!("Public registration {}", if enabled { "enabled" } else { "disabled" }), - "registration_enabled": enabled, - })))) -} +use axum::{ + Router, + extract::{Json, Path, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, + routing::{delete, get, post, put}, +}; + +use crate::application::dtos::settings_dto::{ + AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto, + SaveOidcSettingsDto, TestOidcConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, + UpdateUserRoleDto, +}; +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; + +/// Admin API routes — all require admin role. +pub fn admin_routes() -> Router { + Router::new() + // OIDC settings + .route("/settings/oidc", get(get_oidc_settings)) + .route("/settings/oidc", put(save_oidc_settings)) + .route("/settings/oidc/test", post(test_oidc_connection)) + .route("/settings/general", get(get_general_settings)) + // Dashboard / stats + .route("/dashboard", get(get_dashboard_stats)) + // User management + .route("/users", get(list_users)) + .route("/users", post(create_user)) + .route("/users/{id}", get(get_user)) + .route("/users/{id}", delete(delete_user)) + .route("/users/{id}/role", put(update_user_role)) + .route("/users/{id}/active", put(update_user_active)) + .route("/users/{id}/quota", put(update_user_quota)) + .route("/users/{id}/password", put(reset_user_password)) + // Registration control + .route("/settings/registration", get(get_registration_setting)) + .route("/settings/registration", put(set_registration_setting)) +} + +/// Validate JWT and require admin role. Returns (user_id, role). +async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, String), AppError> { + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; + + let claims = auth + .token_service + .validate_token(token) + .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; + + if claims.role != "admin" { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Admin access required", + "Forbidden", + )); + } + + Ok((claims.sub, claims.role)) +} + +/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel +async fn get_oidc_settings( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let svc = state + .admin_settings_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + let settings = svc + .get_oidc_settings() + .await + .map_err(|e| AppError::internal_error(format!("Failed to load settings: {}", e)))?; + + Ok(Json(settings)) +} + +/// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload +async fn save_oidc_settings( + State(state): State, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + let (user_id, _) = admin_guard(&state, &headers).await?; + + let svc = state + .admin_settings_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + svc.save_oidc_settings(dto, &user_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to save settings: {}", e)))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": "OIDC settings saved and applied successfully" + })), + )) +} + +/// POST /api/admin/settings/oidc/test — test OIDC discovery +async fn test_oidc_connection( + State(state): State, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let svc = state + .admin_settings_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + let result = svc + .test_oidc_connection(dto) + .await + .map_err(|e| AppError::internal_error(format!("Connection test failed: {}", e)))?; + + Ok(Json(result)) +} + +/// GET /api/admin/settings/general — system overview (backward compat) +async fn get_general_settings( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let user_count = auth + .auth_application_service + .count_users_efficient() + .await + .unwrap_or(0); + let oidc_configured = auth.auth_application_service.oidc_enabled(); + + Ok(Json(serde_json::json!({ + "server_version": env!("CARGO_PKG_VERSION"), + "auth_enabled": true, + "total_users": user_count, + "oidc_configured": oidc_configured, + }))) +} + +// ============================================================================ +// Dashboard / Stats +// ============================================================================ + +/// GET /api/admin/dashboard — full dashboard statistics +async fn get_dashboard_stats( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let auth_app = &auth.auth_application_service; + + // Get storage stats from repository (single efficient query) + let db_pool = state + .db_pool + .as_ref() + .ok_or_else(|| AppError::internal_error("Database not available"))?; + + // Use direct SQL for aggregated stats — more efficient than loading all users + let stats_row = sqlx::query( + r#" + SELECT + COUNT(*)::INT8 as total_users, + COUNT(*) FILTER (WHERE active = true)::INT8 as active_users, + COUNT(*) FILTER (WHERE role::text = 'admin')::INT8 as admin_users, + COALESCE(SUM(storage_quota_bytes)::INT8, 0) as total_quota_bytes, + COALESCE(SUM(storage_used_bytes)::INT8, 0) as total_used_bytes, + COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8)::INT8 as users_over_80, + COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes)::INT8 as users_over_quota + FROM auth.users + "# + ) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?; + + use sqlx::Row; + let total_quota: i64 = stats_row.get("total_quota_bytes"); + let total_used: i64 = stats_row.get("total_used_bytes"); + let usage_percent = if total_quota > 0 { + (total_used as f64 / total_quota as f64) * 100.0 + } else { + 0.0 + }; + + let stats = DashboardStatsDto { + server_version: env!("CARGO_PKG_VERSION").to_string(), + auth_enabled: true, + oidc_configured: auth_app.oidc_enabled(), + quotas_enabled: true, // Feature flag could be checked here + total_users: stats_row.get("total_users"), + active_users: stats_row.get("active_users"), + admin_users: stats_row.get("admin_users"), + total_quota_bytes: total_quota, + total_used_bytes: total_used, + storage_usage_percent: (usage_percent * 100.0).round() / 100.0, + users_over_80_percent: stats_row.get("users_over_80"), + users_over_quota: stats_row.get("users_over_quota"), + registration_enabled: { + if let Some(svc) = state.admin_settings_service.as_ref() { + svc.get_registration_enabled().await + } else { + true // default: enabled + } + }, + }; + + Ok(Json(stats)) +} + +// ============================================================================ +// User Management +// ============================================================================ + +/// GET /api/admin/users?limit=50&offset=0 — list all users +async fn list_users( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let limit = query.limit.unwrap_or(100).min(500); + let offset = query.offset.unwrap_or(0); + + let users = auth + .auth_application_service + .list_users(limit, offset) + .await + .map_err(|e| AppError::internal_error(format!("Failed to list users: {}", e)))?; + + let total = auth + .auth_application_service + .count_users_efficient() + .await + .unwrap_or(0); + + Ok(Json(serde_json::json!({ + "users": users, + "total": total, + "limit": limit, + "offset": offset, + }))) +} + +/// GET /api/admin/users/:id — get single user +async fn get_user( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let user = auth + .auth_application_service + .get_user_admin(&id) + .await + .map_err(|e| AppError::not_found(format!("User not found: {}", e)))?; + + Ok(Json(user)) +} + +/// DELETE /api/admin/users/:id — delete a user +async fn delete_user( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + // Prevent self-deletion + if admin_id == id { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Cannot delete your own account", + "SelfDeletion", + )); + } + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .delete_user_admin(&id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete user: {}", e)))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": "User deleted successfully" + })), + )) +} + +/// PUT /api/admin/users/:id/role — change user role +async fn update_user_role( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + // Prevent changing own role + if admin_id == id { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Cannot change your own role", + "SelfRoleChange", + )); + } + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .change_user_role(&id, &dto.role) + .await + .map_err(|e| AppError::internal_error(format!("Failed to change role: {}", e)))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("User role updated to '{}'", dto.role) + })), + )) +} + +/// PUT /api/admin/users/:id/active — activate/deactivate user +async fn update_user_active( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + // Prevent deactivating yourself + if admin_id == id && !dto.active { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Cannot deactivate your own account", + "SelfDeactivation", + )); + } + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .set_user_active(&id, dto.active) + .await + .map_err(|e| AppError::internal_error(format!("Failed to update user status: {}", e)))?; + + let status = if dto.active { + "activated" + } else { + "deactivated" + }; + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("User {}", status) + })), + )) +} + +/// PUT /api/admin/users/:id/quota — update user storage quota +async fn update_user_quota( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .update_user_quota(&id, dto.quota_bytes) + .await + .map_err(|e| AppError::internal_error(format!("Failed to update quota: {}", e)))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": "User quota updated", + "quota_bytes": dto.quota_bytes, + })), + )) +} + +// ============================================================================ +// Admin User Creation & Password Reset +// ============================================================================ + +/// POST /api/admin/users — create a new user (admin only) +async fn create_user( + State(state): State, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let user = auth + .auth_application_service + .admin_create_user(dto) + .await + .map_err(|e| { + AppError::new( + StatusCode::BAD_REQUEST, + format!("Failed to create user: {}", e), + "CreateUserFailed", + ) + })?; + + Ok((StatusCode::CREATED, Json(user))) +} + +/// PUT /api/admin/users/:id/password — reset a user's password (admin only) +async fn reset_user_password( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .admin_reset_password(&id, &dto.new_password) + .await + .map_err(|e| { + AppError::new( + StatusCode::BAD_REQUEST, + format!("Failed to reset password: {}", e), + "ResetPasswordFailed", + ) + })?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Password reset successfully" + })), + )) +} + +// ============================================================================ +// Registration Control +// ============================================================================ + +/// GET /api/admin/settings/registration — check if public registration is enabled +async fn get_registration_setting( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let svc = state + .admin_settings_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + let val = svc.get_registration_enabled().await; + + Ok(Json(serde_json::json!({ + "registration_enabled": val, + }))) +} + +/// PUT /api/admin/settings/registration — enable/disable public registration +async fn set_registration_setting( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + let enabled = body + .get("registration_enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| { + AppError::new( + StatusCode::BAD_REQUEST, + "Missing boolean field 'registration_enabled'", + "InvalidInput", + ) + })?; + + let svc = state + .admin_settings_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + svc.set_registration_enabled(enabled, &admin_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to save setting: {}", e)))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("Public registration {}", if enabled { "enabled" } else { "disabled" }), + "registration_enabled": enabled, + })), + )) +} diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 6498de44..70c6ca2c 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1,17 +1,17 @@ -use std::sync::Arc; use axum::{ Router, - routing::{post, get, put}, - extract::{State, Json, Query}, - http::{StatusCode, HeaderMap, header}, + extract::{Json, Query, State}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Redirect}, + routing::{get, post, put}, }; +use std::sync::Arc; -use crate::common::di::AppState; use crate::application::dtos::user_dto::{ - LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto, - OidcCallbackQueryDto, OidcProviderInfoDto, OidcExchangeDto, + ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, + RefreshTokenDto, RegisterDto, }; +use crate::common::di::AppState; use crate::interfaces::errors::AppError; pub fn auth_routes() -> Router> { @@ -26,14 +26,14 @@ pub fn auth_routes() -> Router> { .route("/oidc/authorize", get(oidc_authorize)) .route("/oidc/callback", get(oidc_callback)) .route("/oidc/exchange", post(oidc_exchange)); - + // Routes that DO require authentication - we use route_layer to apply middleware // The middleware will use the state passed with .with_state() from main.rs let protected_routes = Router::new() .route("/me", get(get_current_user)) .route("/change-password", put(change_password)) .route("/logout", post(logout)); - + // Combine public and protected routes public_routes.merge(protected_routes) } @@ -44,21 +44,26 @@ async fn register( ) -> Result { // Add detailed logging for debugging tracing::info!("Registration attempt for user: {}", dto.username); - + // Verify auth service exists let auth_service = match state.auth_service.as_ref() { Some(service) => { tracing::info!("Auth service found, proceeding with registration"); service - }, + } None => { tracing::error!("Auth service not configured"); - return Err(AppError::internal_error("Authentication service not configured")); + return Err(AppError::internal_error( + "Authentication service not configured", + )); } }; // Fix #5: Block password registration when OIDC-only mode is active - if auth_service.auth_application_service.password_login_disabled() { + if auth_service + .auth_application_service + .password_login_disabled() + { return Err(AppError::new( StatusCode::FORBIDDEN, "Password registration is disabled. Please use SSO/OIDC to sign in.", @@ -68,21 +73,26 @@ async fn register( // Check if public registration has been disabled by the admin if let Some(admin_svc) = state.admin_settings_service.as_ref() - && !admin_svc.get_registration_enabled().await { - return Err(AppError::new( - StatusCode::FORBIDDEN, - "Public registration has been disabled by the administrator.", - "RegistrationDisabled", - )); - } - + && !admin_svc.get_registration_enabled().await + { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Public registration has been disabled by the administrator.", + "RegistrationDisabled", + )); + } + // Registration logic (admin detection, fresh-install handling, duplicate // checks) is all inside the service layer. Call it directly. - match auth_service.auth_application_service.register(dto.clone()).await { + match auth_service + .auth_application_service + .register(dto.clone()) + .await + { Ok(user) => { tracing::info!("Registration successful for user: {}", dto.username); Ok((StatusCode::CREATED, Json(user))) - }, + } Err(err) => { tracing::error!("Registration failed for user {}: {}", dto.username, err); Err(err.into()) @@ -96,41 +106,55 @@ async fn login( ) -> Result { // Add detailed logging for debugging tracing::info!("Login attempt for user: {}", dto.username); - - // Verify auth service exists + + // Verify auth service exists let auth_service = match state.auth_service.as_ref() { Some(service) => { tracing::info!("Auth service found, proceeding with login"); service - }, + } None => { tracing::error!("Auth service not configured"); - return Err(AppError::internal_error("Authentication service not configured")); + return Err(AppError::internal_error( + "Authentication service not configured", + )); } }; // Check if password login is disabled (OIDC-only mode) - if auth_service.auth_application_service.password_login_disabled() { + if auth_service + .auth_application_service + .password_login_disabled() + { return Err(AppError::unauthorized( - "Password login is disabled. Please use SSO/OIDC to sign in." + "Password login is disabled. Please use SSO/OIDC to sign in.", )); } - + // Try the normal login process - match auth_service.auth_application_service.login(dto.clone()).await { + match auth_service + .auth_application_service + .login(dto.clone()) + .await + { Ok(auth_response) => { tracing::info!("Login successful for user: {}", dto.username); // Log the response structure for debugging tracing::debug!("Auth response: {:?}", &auth_response); - + // Ensure the response has the expected fields if auth_response.access_token.is_empty() || auth_response.refresh_token.is_empty() { - tracing::error!("Login response contains empty tokens for user: {}", dto.username); - return Err(AppError::internal_error("Error generating authentication tokens")); + tracing::error!( + "Login response contains empty tokens for user: {}", + dto.username + ); + return Err(AppError::internal_error( + "Error generating authentication tokens", + )); } - + Ok((StatusCode::OK, Json(auth_response))) - }, + } Err(err) => { tracing::error!("Login failed for user {}: {}", dto.username, err); Err(err.into()) @@ -144,19 +168,24 @@ async fn refresh_token( ) -> Result { // Add rate limiting for token refresh to prevent refresh loops // Check if this refresh token is being used too frequently - + // Log the refresh attempt for debugging tracing::info!("Token refresh requested"); - + // Normal process for real tokens - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let auth_response = auth_service.auth_application_service.refresh_token(dto).await?; - + + let auth_response = auth_service + .auth_application_service + .refresh_token(dto) + .await?; + // Log successful token refresh tracing::info!("Token refresh successful, new token issued"); - + Ok((StatusCode::OK, Json(auth_response))) } @@ -165,40 +194,54 @@ async fn get_current_user( headers: HeaderMap, ) -> Result { // Normal process for all users - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - + // Extract and validate the token directly let token = headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - + // Validate the token and get claims - let claims = auth_service.token_service.validate_token(token) + let claims = auth_service + .token_service + .validate_token(token) .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - + let user_id = claims.sub; - + // First, update the storage usage statistics // IMPORTANT: We await the calculation to return updated data if let Some(storage_usage_service) = state.storage_usage_service.as_ref() { // Calculate storage synchronously (we await the result) - match storage_usage_service.update_user_storage_usage(&user_id).await { + match storage_usage_service + .update_user_storage_usage(&user_id) + .await + { Ok(usage) => { - tracing::info!("Updated storage usage for user {}: {} bytes", user_id, usage); - }, + tracing::info!( + "Updated storage usage for user {}: {} bytes", + user_id, + usage + ); + } Err(e) => { // Only log a warning, don't fail the entire request tracing::warn!("Failed to update storage usage for user {}: {}", user_id, e); } } } - + // Now get the user data WITH the updated storage - let user = auth_service.auth_application_service.get_user_by_id(&user_id).await?; - + let user = auth_service + .auth_application_service + .get_user_by_id(&user_id) + .await?; + Ok((StatusCode::OK, Json(user))) } @@ -207,22 +250,29 @@ async fn change_password( headers: HeaderMap, Json(dto): Json, ) -> Result { - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - + // Extract and validate the token directly let token = headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - + // Validate the token and get claims - let claims = auth_service.token_service.validate_token(token) + let claims = auth_service + .token_service + .validate_token(token) .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - auth_service.auth_application_service.change_password(&claims.sub, dto).await?; - + + auth_service + .auth_application_service + .change_password(&claims.sub, dto) + .await?; + Ok(StatusCode::OK) } @@ -230,23 +280,30 @@ async fn logout( State(state): State>, headers: HeaderMap, ) -> Result { - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - + // Extract and validate the token directly let token = headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - + // Validate the token and get claims - let claims = auth_service.token_service.validate_token(token) + let claims = auth_service + .token_service + .validate_token(token) .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - + // Use access token for logout (we don't have refresh token in headers) - auth_service.auth_application_service.logout(&claims.sub, token).await?; - + auth_service + .auth_application_service + .logout(&claims.sub, token) + .await?; + Ok(StatusCode::OK) } @@ -265,21 +322,30 @@ struct SystemStatus { async fn get_system_status( State(state): State>, ) -> Result { - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - + // Count admin users to determine if system is initialized - let admin_count = auth_service.auth_application_service.count_admin_users().await + let admin_count = auth_service + .auth_application_service + .count_admin_users() + .await .unwrap_or(0); - + let status = SystemStatus { initialized: admin_count > 0, admin_count, registration_allowed: admin_count > 0, // Only allow registration if admin exists }; - - tracing::info!("System status check: initialized={}, admin_count={}", status.initialized, status.admin_count); - + + tracing::info!( + "System status check: initialized={}, admin_count={}", + status.initialized, + status.admin_count + ); + Ok((StatusCode::OK, Json(status))) } @@ -288,10 +354,10 @@ async fn get_system_status( // ============================================================================ /// GET /api/auth/oidc/providers — Returns OIDC provider info for the UI -async fn oidc_providers( - State(state): State>, -) -> Result { - let auth_service = state.auth_service.as_ref() +async fn oidc_providers(State(state): State>) -> Result { + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let auth_app = &auth_service.auth_application_service; @@ -316,10 +382,10 @@ async fn oidc_providers( } /// GET /api/auth/oidc/authorize — Redirects user to the OIDC provider -async fn oidc_authorize( - State(state): State>, -) -> Result { - let auth_service = state.auth_service.as_ref() +async fn oidc_authorize(State(state): State>) -> Result { + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let auth_app = &auth_service.auth_application_service; @@ -345,7 +411,9 @@ async fn oidc_callback( State(state): State>, Query(query): Query, ) -> Result { - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; let auth_app = &auth_service.auth_application_service; @@ -361,7 +429,9 @@ async fn oidc_callback( tracing::info!("OIDC callback received with code"); // Exchange code, validate state/nonce/PKCE, authenticate user - let exchange_code = auth_app.oidc_callback(&query.code, &query.state).await + let exchange_code = auth_app + .oidc_callback(&query.code, &query.state) + .await .map_err(|e| { tracing::error!("OIDC callback failed: {}", e); AppError::from(e) @@ -370,11 +440,7 @@ async fn oidc_callback( // Redirect to frontend with one-time exchange code (NOT raw tokens) let config = auth_app.oidc_config().unwrap(); let frontend_url = config.frontend_url.trim_end_matches('/'); - let redirect_url = format!( - "{}/?oidc_code={}", - frontend_url, - exchange_code, - ); + let redirect_url = format!("{}/?oidc_code={}", frontend_url, exchange_code,); tracing::info!("OIDC login successful, redirecting with exchange code"); @@ -387,17 +453,23 @@ async fn oidc_exchange( State(state): State>, Json(body): Json, ) -> Result { - let auth_service = state.auth_service.as_ref() + let auth_service = state + .auth_service + .as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - let auth_response = auth_service.auth_application_service + let auth_response = auth_service + .auth_application_service .exchange_oidc_token(&body.code) .map_err(|e| { tracing::warn!("OIDC token exchange failed: {}", e); AppError::from(e) })?; - tracing::info!("OIDC token exchange successful for user: {}", auth_response.user.username); + tracing::info!( + "OIDC token exchange successful for user: {}", + auth_response.user.username + ); Ok((StatusCode::OK, Json(auth_response))) } diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 99ccb1d5..3d0b576c 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -1,16 +1,16 @@ -use std::sync::Arc; use axum::{ - extract::{State, Json}, - response::IntoResponse, + extract::{Json, State}, http::StatusCode, + response::IntoResponse, }; use serde::{Deserialize, Serialize}; +use std::sync::Arc; -use crate::application::services::batch_operations::{ - BatchOperationService, BatchResult, BatchStats -}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; +use crate::application::services::batch_operations::{ + BatchOperationService, BatchResult, BatchStats, +}; use crate::interfaces::api::handlers::ApiResult; /// Shared state for the batch handler @@ -111,11 +111,13 @@ where { fn from(result: BatchResult) -> Self { let successful = result.successful.into_iter().map(U::from).collect(); - - let failed = result.failed.into_iter() + + let failed = result + .failed + .into_iter() .map(|(id, error)| FailedOperation { id, error }) .collect(); - + Self { successful, failed, @@ -135,19 +137,21 @@ pub async fn move_files_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file IDs provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .move_files(request.file_ids, request.target_folder_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Convert result to DTO let response: BatchOperationResponse = result.into(); - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -158,7 +162,7 @@ pub async fn move_files_batch( } else { StatusCode::OK // All successful }; - + Ok((status_code, Json(response)).into_response()) } @@ -173,19 +177,21 @@ pub async fn copy_files_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file IDs provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .copy_files(request.file_ids, request.target_folder_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Convert result to DTO let response: BatchOperationResponse = result.into(); - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -196,7 +202,7 @@ pub async fn copy_files_batch( } else { StatusCode::OK // All successful }; - + Ok((status_code, Json(response)).into_response()) } @@ -211,25 +217,29 @@ pub async fn delete_files_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file IDs provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .delete_files(request.file_ids) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Create custom response for string IDs let response = BatchOperationResponse { successful: result.successful, - failed: result.failed.into_iter() + failed: result + .failed + .into_iter() .map(|(id, error)| FailedOperation { id, error }) .collect(), stats: result.stats.into(), }; - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -240,7 +250,7 @@ pub async fn delete_files_batch( } else { StatusCode::OK // All successful }; - + Ok((status_code, Json(response)).into_response()) } @@ -255,25 +265,29 @@ pub async fn delete_folders_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No folder IDs provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .delete_folders(request.folder_ids, request.recursive) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Create custom response for string IDs let response = BatchOperationResponse { successful: result.successful, - failed: result.failed.into_iter() + failed: result + .failed + .into_iter() .map(|(id, error)| FailedOperation { id, error }) .collect(), stats: result.stats.into(), }; - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -284,7 +298,7 @@ pub async fn delete_folders_batch( } else { StatusCode::OK // All successful }; - + Ok((status_code, Json(response)).into_response()) } @@ -299,25 +313,28 @@ pub async fn create_folders_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No folders provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Transform the format for the service - let folders = request.folders + let folders = request + .folders .into_iter() .map(|detail| (detail.name, detail.parent_id)) .collect(); - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .create_folders(folders) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Convert result to DTO let response: BatchOperationResponse = result.into(); - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -328,7 +345,7 @@ pub async fn create_folders_batch( } else { StatusCode::CREATED // All successful }; - + Ok((status_code, Json(response)).into_response()) } @@ -343,19 +360,21 @@ pub async fn get_files_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file IDs provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .get_multiple_files(request.file_ids) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Convert result to DTO let response: BatchOperationResponse = result.into(); - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -366,7 +385,7 @@ pub async fn get_files_batch( } else { StatusCode::OK // All successful }; - + Ok((status_code, Json(response)).into_response()) } @@ -381,19 +400,21 @@ pub async fn get_folders_batch( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No folder IDs provided" - })) - ).into_response()); + })), + ) + .into_response()); } - + // Execute batch operation - let result = state.batch_service + let result = state + .batch_service .get_multiple_folders(request.folder_ids) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - + // Convert result to DTO let response: BatchOperationResponse = result.into(); - + // Determine status code based on results let status_code = if response.stats.failed > 0 { if response.stats.successful > 0 { @@ -404,6 +425,6 @@ pub async fn get_folders_batch( } else { StatusCode::OK // All successful }; - + Ok((status_code, Json(response)).into_response()) -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index 9417646d..0455be28 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -1,10 +1,10 @@ /** * 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 @@ -15,30 +15,29 @@ * - DELETE: Remove calendars or events * - PROPPATCH: Modify calendar properties */ - use axum::{ Router, + body::{self, Body}, + http::{HeaderName, Request, StatusCode, header}, response::Response, - http::{StatusCode, header, HeaderName, Request}, - body::{Body, self}, }; -use std::sync::Arc; use bytes::Buf; +use std::sync::Arc; -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, + CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto, }; -use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::ports::calendar_ports::CalendarUseCase; +use crate::common::di::AppState; use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; 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 { @@ -71,7 +70,7 @@ async fn handle_caldav_methods_inner( ) -> 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, @@ -81,7 +80,10 @@ async fn handle_caldav_methods_inner( "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))), + _ => Err(AppError::method_not_allowed(format!( + "Method not allowed: {}", + method + ))), } } @@ -93,7 +95,10 @@ fn extract_caldav_path(uri_path: &str) -> String { } else if uri_path.ends_with("/caldav") { String::new() } else { - uri_path.trim_start_matches('/').trim_end_matches('/').to_string() + uri_path + .trim_start_matches('/') + .trim_end_matches('/') + .to_string() } } @@ -122,7 +127,10 @@ 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") + .header( + header::ALLOW, + "OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCALENDAR", + ) .body(Body::empty()) .unwrap()) } @@ -134,32 +142,39 @@ async fn handle_propfind( req: Request, path: &str, ) -> Result, AppError> { - let depth = req.headers() + 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 } + 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)))? + 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 + 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( @@ -167,8 +182,9 @@ async fn handle_propfind( &calendars, &propfind_request, base_href, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -178,22 +194,26 @@ async fn handle_propfind( // 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 + 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 + 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, @@ -201,8 +221,9 @@ async fn handle_propfind( &propfind_request, base_href, &depth, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -212,27 +233,32 @@ async fn handle_propfind( // 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 + + 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) + + 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)))?; - + ) + .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") @@ -251,44 +277,55 @@ async fn handle_report( ) -> 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)))? + 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)))? + 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 + 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() + + 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)))? - }, + } + 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( @@ -296,8 +333,9 @@ async fn handle_report( &events, &report, base_href, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -314,29 +352,35 @@ async fn handle_mkcalendar( ) -> 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('/').next_back().unwrap_or("New Calendar").to_string(); + let name = path + .split('/') + .next_back() + .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 + + 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()) @@ -352,43 +396,51 @@ async fn handle_put( ) -> 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")); + 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 + 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 + 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 + 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)) @@ -399,10 +451,12 @@ async fn handle_put( calendar_id: calendar_id.to_string(), ical_data, }; - - let event = calendar_service.create_event_from_ical_for_user(create_dto, &user.id).await + + 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)) @@ -431,20 +485,24 @@ async fn handle_get( ) -> 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 + 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 + + 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") @@ -455,15 +513,19 @@ async fn handle_get( // 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 + + 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) + + 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") @@ -530,31 +592,39 @@ async fn handle_delete( ) -> 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 + 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 + + 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) + + 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 + + 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()) @@ -570,27 +640,30 @@ async fn handle_proppatch( ) -> 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()) + + 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()), @@ -599,12 +672,14 @@ async fn handle_proppatch( _ => {} } } - + 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 + 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)); @@ -612,18 +687,19 @@ async fn handle_proppatch( 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)))?; - + ) + .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 9359c8a3..c115bf57 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -1,11 +1,11 @@ /** * 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 @@ -16,36 +16,38 @@ * - DELETE: Remove address books or contacts * - PROPPATCH: Modify address book properties */ - use axum::{ Router, + body::{self, Body}, + http::{HeaderName, Request, StatusCode, header}, response::Response, - http::{StatusCode, header, HeaderName, Request}, - body::{Body, self}, }; -use std::sync::Arc; use bytes::Buf; +use std::sync::Arc; -use crate::common::di::AppState; -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::{ - CreateAddressBookDto, UpdateAddressBookDto, +use crate::application::adapters::carddav_adapter::{ + CardDavAdapter, CardDavReportType, contact_to_vcard, }; +use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType}; +use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto}; use crate::application::dtos::contact_dto::CreateContactVCardDto; -use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; +use crate::common::di::AppState; use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; 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() - .route("/carddav/{*path}", axum::routing::any(handle_carddav_methods)) + .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)) } @@ -73,7 +75,7 @@ async fn handle_carddav_methods_inner( ) -> 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, @@ -83,7 +85,10 @@ async fn handle_carddav_methods_inner( "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))), + _ => Err(AppError::method_not_allowed(format!( + "Method not allowed: {}", + method + ))), } } @@ -95,7 +100,10 @@ fn extract_carddav_path(uri_path: &str) -> String { } else if uri_path.ends_with("/carddav") { String::new() } else { - uri_path.trim_start_matches('/').trim_end_matches('/').to_string() + uri_path + .trim_start_matches('/') + .trim_end_matches('/') + .to_string() } } @@ -134,7 +142,10 @@ 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") + .header( + header::ALLOW, + "OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL", + ) .body(Body::empty()) .unwrap()) } @@ -146,32 +157,41 @@ async fn handle_propfind( req: Request, path: &str, ) -> Result, AppError> { - let depth = req.headers() + 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 } + 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)))? + 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)))?; - + let address_books = addressbook_service + .list_user_address_books(&user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to list address books: {}", e)) + })?; + let base_href = "/carddav/"; let mut response_body = Vec::new(); CardDavAdapter::generate_addressbooks_propfind_response( @@ -179,8 +199,9 @@ async fn handle_propfind( &address_books, &propfind_request, base_href, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -189,22 +210,26 @@ async fn handle_propfind( } 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 + let address_book = addressbook_service + .get_address_book(address_book_id, &user.id) + .await .map_err(|e| AppError::not_found(format!("Address book not found: {}", e)))?; - + let contacts = if depth != "0" { - contact_svc.list_contacts(address_book_id, &user.id).await + contact_svc + .list_contacts(address_book_id, &user.id) + .await .unwrap_or_default() } else { vec![] }; - + let base_href = &format!("/carddav/{}/", address_book_id); let mut response_body = Vec::new(); - + CardDavAdapter::generate_addressbook_collection_propfind( &mut response_body, &address_book, @@ -212,8 +237,9 @@ async fn handle_propfind( &propfind_request, base_href, &depth, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -223,21 +249,27 @@ async fn handle_propfind( // Individual contact .vcf let contact_file = parts[1]; let contact_uid = contact_file.trim_end_matches(".vcf"); - + // Look up by UID across all contacts in this address book - let contacts = contact_svc.list_contacts(address_book_id, &user.id).await + 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 contact = contacts + .iter() + .find(|c| c.uid == contact_uid) + .ok_or_else(|| { + AppError::not_found(format!("Contact not found: {}", contact_uid)) + })?; + // Build single-resource PROPFIND response let base_href = &format!("/carddav/{}/", address_book_id); let report = CardDavReportType::AddressbookMultiget { hrefs: vec![format!("{}{}.vcf", base_href, contact_uid)], props: vec![], }; - + let mut response_body = Vec::new(); CardDavAdapter::generate_contacts_response( &mut response_body, @@ -245,8 +277,9 @@ async fn handle_propfind( &[(contact.uid.clone(), contact_to_vcard(contact))], &report, base_href, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -265,44 +298,48 @@ async fn handle_report( ) -> Result, AppError> { let user = extract_user(&req)?; 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 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)))? - }, + CardDavReportType::AddressbookQuery { .. } => contact_svc + .list_contacts(address_book_id, &user.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?, CardDavReportType::AddressbookMultiget { hrefs, .. } => { - let all_contacts = contact_svc.list_contacts(address_book_id, &user.id).await + 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() + + 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)))? - }, + } + 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() + 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( @@ -311,8 +348,9 @@ async fn handle_report( &vcards, &report, base_href, - ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - + ) + .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") @@ -329,19 +367,23 @@ async fn handle_mkcol( ) -> 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('/').next_back().unwrap_or("New Address Book").to_string(); + let name = path + .split('/') + .next_back() + .unwrap_or("New Address Book") + .to_string(); (name, None, None) } else { CardDavAdapter::parse_mkaddressbook(body_bytes.reader()) .map_err(|e| AppError::bad_request(format!("Failed to parse MKCOL: {}", e)))? }; - + let create_dto = CreateAddressBookDto { name, owner_id: user.id.clone(), @@ -349,10 +391,12 @@ async fn handle_mkcol( color, is_public: Some(false), }; - - addressbook_service.create_address_book(create_dto).await + + 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()) @@ -368,46 +412,54 @@ async fn handle_put( ) -> 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")); + 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 + 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 + 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 + 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)) @@ -419,10 +471,12 @@ async fn handle_put( vcard: vcard_data, user_id: user.id.clone(), }; - - let contact = contact_svc.create_contact_from_vcard(create_dto).await + + 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)) @@ -451,20 +505,22 @@ async fn handle_get( ) -> 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 + 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") @@ -474,15 +530,19 @@ async fn handle_get( // 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 + + 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) + + 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") @@ -502,33 +562,43 @@ async fn handle_delete( 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)))?; + 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 + + 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) + + 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 + + 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()) @@ -544,20 +614,23 @@ async fn handle_proppatch( ) -> 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()) + + 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, @@ -565,7 +638,7 @@ async fn handle_proppatch( 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()), @@ -574,12 +647,16 @@ async fn handle_proppatch( _ => {} } } - + 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)))?; + 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)); @@ -587,15 +664,16 @@ async fn handle_proppatch( 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)))?; - + ) + .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") diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 850d44df..5b372608 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -1,303 +1,352 @@ -//! Chunked Upload Handler - TUS-like Protocol Endpoints -//! -//! Provides HTTP endpoints for resumable, parallel chunk uploads: -//! - POST /api/uploads → Create upload session -//! - PATCH /api/uploads/:id → Upload a chunk -//! - HEAD /api/uploads/:id → Get upload status -//! - POST /api/uploads/:id/complete → Assemble and finalize -//! - DELETE /api/uploads/:id → Cancel upload - -use axum::{ - extract::{Path, State, Query}, - http::{StatusCode, header, HeaderMap}, - response::{IntoResponse, Response}, - Json, -}; -use bytes::Bytes; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -use crate::common::di::AppState; -use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; -use crate::domain::errors::ErrorKind; - -/// Request body for creating an upload session -#[derive(Debug, Deserialize)] -pub struct CreateUploadRequest { - pub filename: String, - pub folder_id: Option, - pub content_type: Option, - pub total_size: u64, - pub chunk_size: Option, -} - -/// Query params for chunk upload -#[derive(Debug, Deserialize)] -pub struct ChunkUploadParams { - pub chunk_index: usize, - pub checksum: Option, -} - -/// Final response after completing upload -#[derive(Debug, Serialize)] -pub struct CompleteUploadResponse { - pub file_id: String, - pub filename: String, - pub size: u64, - pub path: String, -} - -/// Chunked Upload Handler -pub struct ChunkedUploadHandler; - -impl ChunkedUploadHandler { - /// POST /api/uploads - Create a new upload session - /// - /// Request body: - /// ```json - /// { - /// "filename": "large-video.mp4", - /// "folder_id": "optional-folder-id", - /// "content_type": "video/mp4", - /// "total_size": 104857600, - /// "chunk_size": 5242880 - /// } - /// ``` - /// - /// Response: - /// ```json - /// { - /// "upload_id": "uuid", - /// "chunk_size": 5242880, - /// "total_chunks": 20, - /// "expires_at": 86400 - /// } - /// ``` - pub async fn create_upload( - State(state): State>, - Json(request): Json, - ) -> impl IntoResponse { - let chunked_service = &state.core.chunked_upload_service; - - // Validate request - if request.filename.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "Filename is required" - }))).into_response(); - } - - if request.total_size == 0 { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "Total size must be greater than 0" - }))).into_response(); - } - - // Validate chunk size if provided - let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); - if chunk_size < 1024 * 1024 { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "Chunk size must be at least 1MB" - }))).into_response(); - } - - let content_type = request.content_type - .unwrap_or_else(|| "application/octet-stream".to_string()); - - match chunked_service.create_session( - request.filename, - request.folder_id, - content_type, - request.total_size, - Some(chunk_size), - ).await { - Ok(response) => { - (StatusCode::CREATED, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to create upload session: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": e.to_string() - }))).into_response() - } - } - } - - /// PATCH /api/uploads/:upload_id - Upload a chunk - /// - /// Query params: - /// - chunk_index: The index of the chunk (0-based) - /// - checksum: Optional MD5 checksum for verification - /// - /// Body: Raw bytes of the chunk - pub async fn upload_chunk( - State(state): State>, - Path(upload_id): Path, - Query(params): Query, - headers: HeaderMap, - body: Bytes, - ) -> impl IntoResponse { - let chunked_service = &state.core.chunked_upload_service; - - // Extract checksum from header or query param - let checksum = params.checksum.or_else(|| { - headers.get("Content-MD5") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - }); - - match chunked_service.upload_chunk( - &upload_id, - params.chunk_index, - body, - checksum, - ).await { - Ok(response) => { - let mut resp = Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .header("Upload-Offset", response.bytes_received.to_string()) - .header("Upload-Progress", format!("{:.2}", response.progress * 100.0)); - - if response.is_complete { - resp = resp.header("Upload-Complete", "true"); - } - - resp.body(axum::body::Body::from(serde_json::to_string(&response).unwrap())) - .unwrap() - .into_response() - } - Err(e) => { - let status = match e.kind { - ErrorKind::NotFound => StatusCode::NOT_FOUND, - ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, - ErrorKind::AlreadyExists => StatusCode::CONFLICT, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - - (status, Json(serde_json::json!({ - "error": e.to_string() - }))).into_response() - } - } - } - - /// HEAD /api/uploads/:upload_id - Get upload status - /// - /// Returns upload progress and pending chunks - pub async fn get_upload_status( - State(state): State>, - Path(upload_id): Path, - ) -> impl IntoResponse { - let chunked_service = &state.core.chunked_upload_service; - - match chunked_service.get_status(&upload_id).await { - Ok(status) => { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .header("Upload-Offset", status.bytes_received.to_string()) - .header("Upload-Length", status.total_size.to_string()) - .header("Upload-Progress", format!("{:.2}", status.progress * 100.0)) - .header("Upload-Chunks-Total", status.total_chunks.to_string()) - .header("Upload-Chunks-Complete", status.completed_chunks.to_string()) - .body(axum::body::Body::from(serde_json::to_string(&status).unwrap())) - .unwrap() - .into_response() - } - Err(e) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": e.to_string() - }))).into_response() - } - } - } - - /// POST /api/uploads/:upload_id/complete - Finalize upload - /// - /// Assembles all chunks into the final file and creates the file record - pub async fn complete_upload( - State(state): State>, - Path(upload_id): Path, - ) -> impl IntoResponse { - let chunked_service = &state.core.chunked_upload_service; - let upload_service = &state.applications.file_upload_service; - - // Assemble chunks - let (assembled_path, filename, folder_id, content_type, total_size) = - match chunked_service.complete_upload(&upload_id).await { - Ok(result) => result, - Err(e) => { - let status = match e.kind { - ErrorKind::NotFound => StatusCode::NOT_FOUND, - ErrorKind::InvalidInput | ErrorKind::AlreadyExists => StatusCode::CONFLICT, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - - return (status, Json(serde_json::json!({ - "error": e.to_string() - }))).into_response(); - } - }; - - // Read assembled file and create final file record - let file_data = match tokio::fs::read(&assembled_path).await { - Ok(data) => data, - Err(e) => { - tracing::error!("Failed to read assembled file: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Failed to read assembled file: {}", e) - }))).into_response(); - } - }; - - // Upload via normal service (this handles path resolution, metadata, etc.) - match upload_service.upload_file( - filename.clone(), - folder_id.clone(), - content_type, - file_data, - ).await { - Ok(file) => { - // Cleanup session - let _ = chunked_service.finalize_upload(&upload_id).await; - - tracing::info!( - "✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)", - filename, file.id, total_size - ); - - (StatusCode::CREATED, Json(CompleteUploadResponse { - file_id: file.id, - filename: file.name, - size: total_size, - path: file.path, - })).into_response() - } - Err(e) => { - tracing::error!("Failed to create file from assembled upload: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Failed to create file: {:?}", e) - }))).into_response() - } - } - } - - /// DELETE /api/uploads/:upload_id - Cancel upload - /// - /// Cancels an in-progress upload and cleans up temp files - pub async fn cancel_upload( - State(state): State>, - Path(upload_id): Path, - ) -> impl IntoResponse { - let chunked_service = &state.core.chunked_upload_service; - - match chunked_service.cancel_upload(&upload_id).await { - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": e.to_string() - }))).into_response() - } - } - } -} +//! Chunked Upload Handler - TUS-like Protocol Endpoints +//! +//! Provides HTTP endpoints for resumable, parallel chunk uploads: +//! - POST /api/uploads → Create upload session +//! - PATCH /api/uploads/:id → Upload a chunk +//! - HEAD /api/uploads/:id → Get upload status +//! - POST /api/uploads/:id/complete → Assemble and finalize +//! - DELETE /api/uploads/:id → Cancel upload + +use axum::{ + Json, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; +use crate::common::di::AppState; +use crate::domain::errors::ErrorKind; + +/// Request body for creating an upload session +#[derive(Debug, Deserialize)] +pub struct CreateUploadRequest { + pub filename: String, + pub folder_id: Option, + pub content_type: Option, + pub total_size: u64, + pub chunk_size: Option, +} + +/// Query params for chunk upload +#[derive(Debug, Deserialize)] +pub struct ChunkUploadParams { + pub chunk_index: usize, + pub checksum: Option, +} + +/// Final response after completing upload +#[derive(Debug, Serialize)] +pub struct CompleteUploadResponse { + pub file_id: String, + pub filename: String, + pub size: u64, + pub path: String, +} + +/// Chunked Upload Handler +pub struct ChunkedUploadHandler; + +impl ChunkedUploadHandler { + /// POST /api/uploads - Create a new upload session + /// + /// Request body: + /// ```json + /// { + /// "filename": "large-video.mp4", + /// "folder_id": "optional-folder-id", + /// "content_type": "video/mp4", + /// "total_size": 104857600, + /// "chunk_size": 5242880 + /// } + /// ``` + /// + /// Response: + /// ```json + /// { + /// "upload_id": "uuid", + /// "chunk_size": 5242880, + /// "total_chunks": 20, + /// "expires_at": 86400 + /// } + /// ``` + pub async fn create_upload( + State(state): State>, + Json(request): Json, + ) -> impl IntoResponse { + let chunked_service = &state.core.chunked_upload_service; + + // Validate request + if request.filename.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Filename is required" + })), + ) + .into_response(); + } + + if request.total_size == 0 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Total size must be greater than 0" + })), + ) + .into_response(); + } + + // Validate chunk size if provided + let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); + if chunk_size < 1024 * 1024 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Chunk size must be at least 1MB" + })), + ) + .into_response(); + } + + let content_type = request + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()); + + match chunked_service + .create_session( + request.filename, + request.folder_id, + content_type, + request.total_size, + Some(chunk_size), + ) + .await + { + Ok(response) => (StatusCode::CREATED, Json(response)).into_response(), + Err(e) => { + tracing::error!("Failed to create upload session: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response() + } + } + } + + /// PATCH /api/uploads/:upload_id - Upload a chunk + /// + /// Query params: + /// - chunk_index: The index of the chunk (0-based) + /// - checksum: Optional MD5 checksum for verification + /// + /// Body: Raw bytes of the chunk + pub async fn upload_chunk( + State(state): State>, + Path(upload_id): Path, + Query(params): Query, + headers: HeaderMap, + body: Bytes, + ) -> impl IntoResponse { + let chunked_service = &state.core.chunked_upload_service; + + // Extract checksum from header or query param + let checksum = params.checksum.or_else(|| { + headers + .get("Content-MD5") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }); + + match chunked_service + .upload_chunk(&upload_id, params.chunk_index, body, checksum) + .await + { + Ok(response) => { + let mut resp = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .header("Upload-Offset", response.bytes_received.to_string()) + .header( + "Upload-Progress", + format!("{:.2}", response.progress * 100.0), + ); + + if response.is_complete { + resp = resp.header("Upload-Complete", "true"); + } + + resp.body(axum::body::Body::from( + serde_json::to_string(&response).unwrap(), + )) + .unwrap() + .into_response() + } + Err(e) => { + let status = match e.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, + ErrorKind::AlreadyExists => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + ( + status, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response() + } + } + } + + /// HEAD /api/uploads/:upload_id - Get upload status + /// + /// Returns upload progress and pending chunks + pub async fn get_upload_status( + State(state): State>, + Path(upload_id): Path, + ) -> impl IntoResponse { + let chunked_service = &state.core.chunked_upload_service; + + match chunked_service.get_status(&upload_id).await { + Ok(status) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .header("Upload-Offset", status.bytes_received.to_string()) + .header("Upload-Length", status.total_size.to_string()) + .header("Upload-Progress", format!("{:.2}", status.progress * 100.0)) + .header("Upload-Chunks-Total", status.total_chunks.to_string()) + .header( + "Upload-Chunks-Complete", + status.completed_chunks.to_string(), + ) + .body(axum::body::Body::from( + serde_json::to_string(&status).unwrap(), + )) + .unwrap() + .into_response(), + Err(e) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(), + } + } + + /// POST /api/uploads/:upload_id/complete - Finalize upload + /// + /// Assembles all chunks into the final file and creates the file record + pub async fn complete_upload( + State(state): State>, + Path(upload_id): Path, + ) -> impl IntoResponse { + let chunked_service = &state.core.chunked_upload_service; + let upload_service = &state.applications.file_upload_service; + + // Assemble chunks + let (assembled_path, filename, folder_id, content_type, total_size) = + match chunked_service.complete_upload(&upload_id).await { + Ok(result) => result, + Err(e) => { + let status = match e.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::InvalidInput | ErrorKind::AlreadyExists => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + return ( + status, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + }; + + // Read assembled file and create final file record + let file_data = match tokio::fs::read(&assembled_path).await { + Ok(data) => data, + Err(e) => { + tracing::error!("Failed to read assembled file: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to read assembled file: {}", e) + })), + ) + .into_response(); + } + }; + + // Upload via normal service (this handles path resolution, metadata, etc.) + match upload_service + .upload_file(filename.clone(), folder_id.clone(), content_type, file_data) + .await + { + Ok(file) => { + // Cleanup session + let _ = chunked_service.finalize_upload(&upload_id).await; + + tracing::info!( + "✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)", + filename, + file.id, + total_size + ); + + ( + StatusCode::CREATED, + Json(CompleteUploadResponse { + file_id: file.id, + filename: file.name, + size: total_size, + path: file.path, + }), + ) + .into_response() + } + Err(e) => { + tracing::error!("Failed to create file from assembled upload: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to create file: {:?}", e) + })), + ) + .into_response() + } + } + } + + /// DELETE /api/uploads/:upload_id - Cancel upload + /// + /// Cancels an in-progress upload and cleans up temp files + pub async fn cancel_upload( + State(state): State>, + Path(upload_id): Path, + ) -> impl IntoResponse { + let chunked_service = &state.core.chunked_upload_service; + + match chunked_service.cancel_upload(&upload_id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(), + } + } +} diff --git a/src/interfaces/api/handlers/dedup_handler.rs b/src/interfaces/api/handlers/dedup_handler.rs index b4f32060..5515213c 100644 --- a/src/interfaces/api/handlers/dedup_handler.rs +++ b/src/interfaces/api/handlers/dedup_handler.rs @@ -1,428 +1,441 @@ -use axum::{ - extract::{Path, State, Multipart}, - http::{StatusCode, header, Response}, - response::IntoResponse, - body::Body, -}; -use bytes::Bytes; -use serde::Serialize; - -use crate::common::di::AppState; -use crate::application::ports::dedup_ports::DedupResultDto; - -/// Global application state for dependency injection -type GlobalState = AppState; - -/// Response for hash check endpoint -#[derive(Debug, Serialize)] -pub struct HashCheckResponse { - /// Whether a blob with this hash already exists - pub exists: bool, - /// The SHA-256 hash that was checked - pub hash: String, - /// If exists, the size of the existing blob - #[serde(skip_serializing_if = "Option::is_none")] - pub existing_size: Option, - /// If exists, the number of references to this blob - #[serde(skip_serializing_if = "Option::is_none")] - pub ref_count: Option, -} - -/// Response for upload with dedup endpoint -#[derive(Debug, Serialize)] -pub struct DedupUploadResponse { - /// Whether this was a new file or an existing one - pub is_new: bool, - /// The SHA-256 hash of the content - pub hash: String, - /// The size of the content in bytes - pub size: u64, - /// Bytes saved by deduplication (0 if new file) - pub bytes_saved: u64, - /// Current reference count for this blob - pub ref_count: u32, -} - -/// Response for dedup stats endpoint -#[derive(Debug, Serialize)] -pub struct StatsResponse { - /// Total number of unique blobs stored - pub unique_blobs: u64, - /// Total number of references (files pointing to blobs) - pub total_references: u64, - /// Total bytes saved by deduplication - pub bytes_saved: u64, - /// Total logical bytes (what users think they have) - pub total_logical_bytes: u64, - /// Total physical bytes (actual disk usage) - pub total_physical_bytes: u64, - /// Deduplication ratio (logical / physical) - pub dedup_ratio: f64, - /// Percentage of storage saved - pub savings_percentage: f64, -} - -/// Handler for deduplication-related endpoints -/// -/// Provides endpoints for: -/// - Checking if content already exists (by hash) -/// - Uploading files with automatic deduplication -/// - Getting deduplication statistics -pub struct DedupHandler; - -impl DedupHandler { - /// Check if a blob with the given hash already exists - /// - /// This endpoint allows clients to check if uploading a file is necessary - /// by pre-computing the hash client-side and checking against the server. - /// - /// GET /api/dedup/check/{hash} - pub async fn check_hash( - State(state): State, - Path(hash): Path, - ) -> impl IntoResponse { - let dedup = &state.core.dedup_service; - - // Validate hash format (SHA-256 = 64 hex chars) - if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"error": "Invalid hash format. Expected SHA-256 (64 hex characters)"}"#)) - .unwrap() - .into_response(); - } - - match dedup.get_blob_metadata(&hash).await { - Some(metadata) => { - let response = HashCheckResponse { - exists: true, - hash, - existing_size: Some(metadata.size), - ref_count: Some(metadata.ref_count), - }; - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&response).unwrap())) - .unwrap() - .into_response() - } - None => { - let response = HashCheckResponse { - exists: false, - hash, - existing_size: None, - ref_count: None, - }; - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&response).unwrap())) - .unwrap() - .into_response() - } - } - } - - /// Upload content with automatic deduplication - /// - /// This endpoint calculates the SHA-256 hash of the uploaded content - /// and either creates a new blob or increments the reference count - /// of an existing blob. - /// - /// POST /api/dedup/upload - /// - /// Returns information about whether the content was new or deduplicated. - pub async fn upload_with_dedup( - State(state): State, - mut multipart: Multipart, - ) -> impl IntoResponse { - let dedup = &state.core.dedup_service; - - // Process multipart form - while let Some(field) = multipart.next_field().await.unwrap_or(None) { - let name = field.name().unwrap_or("").to_string(); - - if name == "file" { - let content_type = field.content_type() - .unwrap_or("application/octet-stream") - .to_string(); - - // Collect all chunks - let mut chunks: Vec = Vec::new(); - let mut total_size: usize = 0; - let mut field = field; - - while let Ok(Some(chunk)) = field.chunk().await { - total_size += chunk.len(); - chunks.push(chunk); - } - - if chunks.is_empty() { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"error": "Empty file not allowed"}"#)) - .unwrap() - .into_response(); - } - - // Combine chunks - let data: Vec = if chunks.len() == 1 { - chunks.into_iter().next().unwrap().to_vec() - } else { - let mut combined = Vec::with_capacity(total_size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - combined - }; - - // Store with deduplication - match dedup.store_bytes(&data, Some(content_type)).await { - Ok(result) => { - let (is_new, bytes_saved) = match &result { - DedupResultDto::NewBlob { .. } => (true, 0), - DedupResultDto::ExistingBlob { saved_bytes, .. } => (false, *saved_bytes), - }; - - let metadata = dedup.get_blob_metadata(result.hash()).await; - - let response = DedupUploadResponse { - is_new, - hash: result.hash().to_string(), - size: result.size(), - bytes_saved, - ref_count: metadata.map(|m| m.ref_count).unwrap_or(1), - }; - - tracing::info!( - "🔗 Dedup upload: hash={}, new={}, saved={}", - result.hash(), - is_new, - bytes_saved - ); - - return Response::builder() - .status(if is_new { StatusCode::CREATED } else { StatusCode::OK }) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&response).unwrap())) - .unwrap() - .into_response(); - } - Err(e) => { - tracing::error!("❌ Dedup upload failed: {}", e); - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(format!(r#"{{"error": "Upload failed: {}"}}"#, e))) - .unwrap() - .into_response(); - } - } - } - } - - Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"error": "No file field found in multipart form"}"#)) - .unwrap() - .into_response() - } - - /// Get deduplication statistics - /// - /// GET /api/dedup/stats - /// - /// Returns comprehensive statistics about the deduplication system including: - /// - Number of unique blobs - /// - Total references - /// - Bytes saved - /// - Deduplication ratio - pub async fn get_stats( - State(state): State, - ) -> impl IntoResponse { - let dedup = &state.core.dedup_service; - let stats = dedup.get_stats().await; - - // Calculate savings percentage - let savings_pct = if stats.total_bytes_referenced > 0 { - (stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0 - } else { - 0.0 - }; - - let response = StatsResponse { - unique_blobs: stats.total_blobs, - total_references: stats.dedup_hits + stats.total_blobs, // Approximation - bytes_saved: stats.bytes_saved, - total_logical_bytes: stats.total_bytes_referenced, - total_physical_bytes: stats.total_bytes_stored, - dedup_ratio: stats.dedup_ratio, - savings_percentage: savings_pct, - }; - - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&response).unwrap())) - .unwrap() - .into_response() - } - - /// Retrieve content by hash - /// - /// GET /api/dedup/blob/{hash} - /// - /// Returns the raw content of a blob identified by its SHA-256 hash. - /// Useful for retrieving deduplicated content. - pub async fn get_blob( - State(state): State, - Path(hash): Path, - ) -> impl IntoResponse { - let dedup = &state.core.dedup_service; - - // Validate hash format - if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"error": "Invalid hash format"}"#)) - .unwrap() - .into_response(); - } - - // Get metadata first for content-type - let metadata = dedup.get_blob_metadata(&hash).await; - let content_type = metadata - .as_ref() - .and_then(|m| m.content_type.clone()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - - match dedup.read_blob_bytes(&hash).await { - Ok(content) => { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, content_type) - .header(header::CONTENT_LENGTH, content.len().to_string()) - .header("X-Dedup-Hash", &hash) - .body(Body::from(content)) - .unwrap() - .into_response() - } - Err(_) => { - Response::builder() - .status(StatusCode::NOT_FOUND) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"error": "Blob not found"}"#)) - .unwrap() - .into_response() - } - } - } - - /// Remove a reference to a blob - /// - /// DELETE /api/dedup/blob/{hash} - /// - /// Decrements the reference count for a blob. If the reference count - /// reaches zero, the blob is deleted from storage. - pub async fn remove_reference( - State(state): State, - Path(hash): Path, - ) -> impl IntoResponse { - let dedup = &state.core.dedup_service; - - // Validate hash format - if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"error": "Invalid hash format"}"#)) - .unwrap() - .into_response(); - } - - match dedup.remove_reference(&hash).await { - Ok(deleted) => { - let message = if deleted { - format!(r#"{{"success": true, "deleted": true, "message": "Blob {} was deleted (ref_count reached 0)"}}"#, hash) - } else { - format!(r#"{{"success": true, "deleted": false, "message": "Reference removed from blob {}"}}"#, hash) - }; - - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(message)) - .unwrap() - .into_response() - } - Err(e) => { - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(format!(r#"{{"error": "{}"}}"#, e))) - .unwrap() - .into_response() - } - } - } - - /// Force recalculation of statistics from disk - /// - /// POST /api/dedup/recalculate - /// - /// Verifies integrity and returns current statistics. - /// Useful for health checks and auditing. - pub async fn recalculate_stats( - State(state): State, - ) -> impl IntoResponse { - let dedup = &state.core.dedup_service; - - // Verify integrity first - match dedup.verify_integrity().await { - Ok(issues) => { - if !issues.is_empty() { - tracing::warn!("Dedup integrity issues found: {:?}", issues); - } - } - Err(e) => { - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(format!(r#"{{"error": "Verification failed: {}"}}"#, e))) - .unwrap() - .into_response(); - } - } - - let stats = dedup.get_stats().await; - - // Calculate savings percentage - let savings_pct = if stats.total_bytes_referenced > 0 { - (stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0 - } else { - 0.0 - }; - - let response = StatsResponse { - unique_blobs: stats.total_blobs, - total_references: stats.dedup_hits + stats.total_blobs, - bytes_saved: stats.bytes_saved, - total_logical_bytes: stats.total_bytes_referenced, - total_physical_bytes: stats.total_bytes_stored, - dedup_ratio: stats.dedup_ratio, - savings_percentage: savings_pct, - }; - - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&response).unwrap())) - .unwrap() - .into_response() - } -} +use axum::{ + body::Body, + extract::{Multipart, Path, State}, + http::{Response, StatusCode, header}, + response::IntoResponse, +}; +use bytes::Bytes; +use serde::Serialize; + +use crate::application::ports::dedup_ports::DedupResultDto; +use crate::common::di::AppState; + +/// Global application state for dependency injection +type GlobalState = AppState; + +/// Response for hash check endpoint +#[derive(Debug, Serialize)] +pub struct HashCheckResponse { + /// Whether a blob with this hash already exists + pub exists: bool, + /// The SHA-256 hash that was checked + pub hash: String, + /// If exists, the size of the existing blob + #[serde(skip_serializing_if = "Option::is_none")] + pub existing_size: Option, + /// If exists, the number of references to this blob + #[serde(skip_serializing_if = "Option::is_none")] + pub ref_count: Option, +} + +/// Response for upload with dedup endpoint +#[derive(Debug, Serialize)] +pub struct DedupUploadResponse { + /// Whether this was a new file or an existing one + pub is_new: bool, + /// The SHA-256 hash of the content + pub hash: String, + /// The size of the content in bytes + pub size: u64, + /// Bytes saved by deduplication (0 if new file) + pub bytes_saved: u64, + /// Current reference count for this blob + pub ref_count: u32, +} + +/// Response for dedup stats endpoint +#[derive(Debug, Serialize)] +pub struct StatsResponse { + /// Total number of unique blobs stored + pub unique_blobs: u64, + /// Total number of references (files pointing to blobs) + pub total_references: u64, + /// Total bytes saved by deduplication + pub bytes_saved: u64, + /// Total logical bytes (what users think they have) + pub total_logical_bytes: u64, + /// Total physical bytes (actual disk usage) + pub total_physical_bytes: u64, + /// Deduplication ratio (logical / physical) + pub dedup_ratio: f64, + /// Percentage of storage saved + pub savings_percentage: f64, +} + +/// Handler for deduplication-related endpoints +/// +/// Provides endpoints for: +/// - Checking if content already exists (by hash) +/// - Uploading files with automatic deduplication +/// - Getting deduplication statistics +pub struct DedupHandler; + +impl DedupHandler { + /// Check if a blob with the given hash already exists + /// + /// This endpoint allows clients to check if uploading a file is necessary + /// by pre-computing the hash client-side and checking against the server. + /// + /// GET /api/dedup/check/{hash} + pub async fn check_hash( + State(state): State, + Path(hash): Path, + ) -> impl IntoResponse { + let dedup = &state.core.dedup_service; + + // Validate hash format (SHA-256 = 64 hex chars) + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"error": "Invalid hash format. Expected SHA-256 (64 hex characters)"}"#, + )) + .unwrap() + .into_response(); + } + + match dedup.get_blob_metadata(&hash).await { + Some(metadata) => { + let response = HashCheckResponse { + exists: true, + hash, + existing_size: Some(metadata.size), + ref_count: Some(metadata.ref_count), + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&response).unwrap())) + .unwrap() + .into_response() + } + None => { + let response = HashCheckResponse { + exists: false, + hash, + existing_size: None, + ref_count: None, + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&response).unwrap())) + .unwrap() + .into_response() + } + } + } + + /// Upload content with automatic deduplication + /// + /// This endpoint calculates the SHA-256 hash of the uploaded content + /// and either creates a new blob or increments the reference count + /// of an existing blob. + /// + /// POST /api/dedup/upload + /// + /// Returns information about whether the content was new or deduplicated. + pub async fn upload_with_dedup( + State(state): State, + mut multipart: Multipart, + ) -> impl IntoResponse { + let dedup = &state.core.dedup_service; + + // Process multipart form + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let name = field.name().unwrap_or("").to_string(); + + if name == "file" { + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + // Collect all chunks + let mut chunks: Vec = Vec::new(); + let mut total_size: usize = 0; + let mut field = field; + + while let Ok(Some(chunk)) = field.chunk().await { + total_size += chunk.len(); + chunks.push(chunk); + } + + if chunks.is_empty() { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"error": "Empty file not allowed"}"#)) + .unwrap() + .into_response(); + } + + // Combine chunks + let data: Vec = if chunks.len() == 1 { + chunks.into_iter().next().unwrap().to_vec() + } else { + let mut combined = Vec::with_capacity(total_size); + for chunk in chunks { + combined.extend_from_slice(&chunk); + } + combined + }; + + // Store with deduplication + match dedup.store_bytes(&data, Some(content_type)).await { + Ok(result) => { + let (is_new, bytes_saved) = match &result { + DedupResultDto::NewBlob { .. } => (true, 0), + DedupResultDto::ExistingBlob { saved_bytes, .. } => { + (false, *saved_bytes) + } + }; + + let metadata = dedup.get_blob_metadata(result.hash()).await; + + let response = DedupUploadResponse { + is_new, + hash: result.hash().to_string(), + size: result.size(), + bytes_saved, + ref_count: metadata.map(|m| m.ref_count).unwrap_or(1), + }; + + tracing::info!( + "🔗 Dedup upload: hash={}, new={}, saved={}", + result.hash(), + is_new, + bytes_saved + ); + + return Response::builder() + .status(if is_new { + StatusCode::CREATED + } else { + StatusCode::OK + }) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&response).unwrap())) + .unwrap() + .into_response(); + } + Err(e) => { + tracing::error!("❌ Dedup upload failed: {}", e); + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"error": "Upload failed: {}"}}"#, + e + ))) + .unwrap() + .into_response(); + } + } + } + } + + Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"error": "No file field found in multipart form"}"#, + )) + .unwrap() + .into_response() + } + + /// Get deduplication statistics + /// + /// GET /api/dedup/stats + /// + /// Returns comprehensive statistics about the deduplication system including: + /// - Number of unique blobs + /// - Total references + /// - Bytes saved + /// - Deduplication ratio + pub async fn get_stats(State(state): State) -> impl IntoResponse { + let dedup = &state.core.dedup_service; + let stats = dedup.get_stats().await; + + // Calculate savings percentage + let savings_pct = if stats.total_bytes_referenced > 0 { + (stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0 + } else { + 0.0 + }; + + let response = StatsResponse { + unique_blobs: stats.total_blobs, + total_references: stats.dedup_hits + stats.total_blobs, // Approximation + bytes_saved: stats.bytes_saved, + total_logical_bytes: stats.total_bytes_referenced, + total_physical_bytes: stats.total_bytes_stored, + dedup_ratio: stats.dedup_ratio, + savings_percentage: savings_pct, + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&response).unwrap())) + .unwrap() + .into_response() + } + + /// Retrieve content by hash + /// + /// GET /api/dedup/blob/{hash} + /// + /// Returns the raw content of a blob identified by its SHA-256 hash. + /// Useful for retrieving deduplicated content. + pub async fn get_blob( + State(state): State, + Path(hash): Path, + ) -> impl IntoResponse { + let dedup = &state.core.dedup_service; + + // Validate hash format + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"error": "Invalid hash format"}"#)) + .unwrap() + .into_response(); + } + + // Get metadata first for content-type + let metadata = dedup.get_blob_metadata(&hash).await; + let content_type = metadata + .as_ref() + .and_then(|m| m.content_type.clone()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + match dedup.read_blob_bytes(&hash).await { + Ok(content) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, content.len().to_string()) + .header("X-Dedup-Hash", &hash) + .body(Body::from(content)) + .unwrap() + .into_response(), + Err(_) => Response::builder() + .status(StatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"error": "Blob not found"}"#)) + .unwrap() + .into_response(), + } + } + + /// Remove a reference to a blob + /// + /// DELETE /api/dedup/blob/{hash} + /// + /// Decrements the reference count for a blob. If the reference count + /// reaches zero, the blob is deleted from storage. + pub async fn remove_reference( + State(state): State, + Path(hash): Path, + ) -> impl IntoResponse { + let dedup = &state.core.dedup_service; + + // Validate hash format + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"error": "Invalid hash format"}"#)) + .unwrap() + .into_response(); + } + + match dedup.remove_reference(&hash).await { + Ok(deleted) => { + let message = if deleted { + format!( + r#"{{"success": true, "deleted": true, "message": "Blob {} was deleted (ref_count reached 0)"}}"#, + hash + ) + } else { + format!( + r#"{{"success": true, "deleted": false, "message": "Reference removed from blob {}"}}"#, + hash + ) + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(message)) + .unwrap() + .into_response() + } + Err(e) => Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!(r#"{{"error": "{}"}}"#, e))) + .unwrap() + .into_response(), + } + } + + /// Force recalculation of statistics from disk + /// + /// POST /api/dedup/recalculate + /// + /// Verifies integrity and returns current statistics. + /// Useful for health checks and auditing. + pub async fn recalculate_stats(State(state): State) -> impl IntoResponse { + let dedup = &state.core.dedup_service; + + // Verify integrity first + match dedup.verify_integrity().await { + Ok(issues) => { + if !issues.is_empty() { + tracing::warn!("Dedup integrity issues found: {:?}", issues); + } + } + Err(e) => { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"error": "Verification failed: {}"}}"#, + e + ))) + .unwrap() + .into_response(); + } + } + + let stats = dedup.get_stats().await; + + // Calculate savings percentage + let savings_pct = if stats.total_bytes_referenced > 0 { + (stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0 + } else { + 0.0 + }; + + let response = StatsResponse { + unique_blobs: stats.total_blobs, + total_references: stats.dedup_hits + stats.total_blobs, + bytes_saved: stats.bytes_saved, + total_logical_bytes: stats.total_bytes_referenced, + total_physical_bytes: stats.total_bytes_stored, + dedup_ratio: stats.dedup_ratio, + savings_percentage: savings_pct, + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&response).unwrap())) + .unwrap() + .into_response() + } +} diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index da83c30b..cce59817 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -1,10 +1,10 @@ -use std::sync::Arc; use axum::{ + Json, extract::{Path, State}, http::StatusCode, response::IntoResponse, - Json, }; +use std::sync::Arc; use tracing::{error, info}; use crate::application::ports::favorites_ports::FavoritesUseCase; @@ -16,20 +16,21 @@ pub async fn get_favorites( auth_user: AuthUser, ) -> impl IntoResponse { let user_id = &auth_user.id; - + match favorites_service.get_favorites(user_id).await { Ok(favorites) => { info!("Retrieved {} favorites for user", favorites.len()); (StatusCode::OK, Json(serde_json::json!(favorites))).into_response() - }, + } Err(err) => { error!("Error retrieving favorites: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to retrieve favorites: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } @@ -41,34 +42,37 @@ pub async fn add_favorite( Path((item_type, item_id)): Path<(String, String)>, ) -> impl IntoResponse { let user_id = &auth_user.id; - + // Validate item_type if item_type != "file" && item_type != "folder" { return ( - StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Item type must be 'file' or 'folder'" - })) + })), ); } - - match favorites_service.add_to_favorites(user_id, &item_id, &item_type).await { + + match favorites_service + .add_to_favorites(user_id, &item_id, &item_type) + .await + { Ok(_) => { info!("Added {} '{}' to favorites", item_type, item_id); ( - StatusCode::CREATED, + StatusCode::CREATED, Json(serde_json::json!({ "message": "Item added to favorites" - })) + })), ) - }, + } Err(err) => { error!("Error adding to favorites: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to add to favorites: {}", err) - })) + })), ) } } @@ -81,35 +85,38 @@ pub async fn remove_favorite( Path((item_type, item_id)): Path<(String, String)>, ) -> impl IntoResponse { let user_id = &auth_user.id; - - match favorites_service.remove_from_favorites(user_id, &item_id, &item_type).await { + + match favorites_service + .remove_from_favorites(user_id, &item_id, &item_type) + .await + { Ok(removed) => { if removed { info!("Removed {} '{}' from favorites", item_type, item_id); ( - StatusCode::OK, + StatusCode::OK, Json(serde_json::json!({ "message": "Item removed from favorites" - })) + })), ) } else { info!("Item {} '{}' was not in favorites", item_type, item_id); ( - StatusCode::NOT_FOUND, + StatusCode::NOT_FOUND, Json(serde_json::json!({ "message": "Item was not in favorites" - })) + })), ) } - }, + } Err(err) => { error!("Error removing from favorites: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to remove from favorites: {}", err) - })) + })), ) } } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 80eb5054..3e9d3305 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -1,16 +1,16 @@ use axum::{ - extract::{Path, State, Multipart, Query}, - http::{StatusCode, header, HeaderMap, Response}, - response::IntoResponse, - body::Body, Json, + body::Body, + extract::{Multipart, Path, Query, State}, + http::{HeaderMap, Response, StatusCode, header}, + response::IntoResponse, }; use bytes::Bytes; +use http_range_header::parse_range_header; use serde::Deserialize; use std::collections::HashMap; -use http_range_header::parse_range_header; -use crate::application::ports::compression_ports::{CompressionPort, CompressionLevel}; +use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; use crate::application::ports::file_ports::OptimizedFileContent; use crate::common::di::AppState; use crate::interfaces::middleware::auth::OptionalUserId; @@ -23,7 +23,7 @@ type GlobalState = AppState; /** * API handler for file-related operations. - * + * * Acts as a thin HTTP adapter in the hexagonal architecture: it parses requests, * delegates business logic to application services, and maps results to HTTP * responses. No infrastructure or strategy logic lives here. @@ -36,7 +36,7 @@ impl FileHandler { // ═══════════════════════════════════════════════════════════════════════ /// Uploads a file with TRUE STREAMING support and Write-Behind Cache - /// + /// /// The three-tier strategy (write-behind / buffered / streaming) and dedup /// are fully handled by `FileUploadUseCase::smart_upload`. /// This handler only extracts multipart fields and maps the result to HTTP. @@ -53,13 +53,18 @@ impl FileHandler { if name == "folder_id" { let v = field.text().await.unwrap_or_default(); - if !v.is_empty() { folder_id = Some(v); } + if !v.is_empty() { + folder_id = Some(v); + } continue; } if name == "file" { let filename = field.file_name().unwrap_or("unnamed").to_string(); - let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); // Collect chunks from multipart let mut chunks: Vec = Vec::new(); @@ -73,7 +78,10 @@ impl FileHandler { // Empty file if chunks.is_empty() { let upload_service = &state.applications.file_upload_service; - return match upload_service.upload_file(filename, folder_id, content_type, vec![]).await { + return match upload_service + .upload_file(filename, folder_id, content_type, vec![]) + .await + { Ok(file) => Self::created_json_response(&file).into_response(), Err(err) => Self::domain_error_response(err).into_response(), }; @@ -82,7 +90,10 @@ impl FileHandler { // Delegate to FileService (simple path, no write-behind/dedup) let upload_service = &state.applications.file_upload_service; let data = Self::combine_chunks(chunks, total_size); - match upload_service.upload_file(filename.clone(), folder_id, content_type, data).await { + match upload_service + .upload_file(filename.clone(), folder_id, content_type, data) + .await + { Ok(file) => { tracing::info!("✅ UPLOAD COMPLETE: {} (ID: {})", filename, file.id); return Self::created_json_response(&file); @@ -95,9 +106,13 @@ impl FileHandler { } } - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "No file provided" - }))).into_response() + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "No file provided" + })), + ) + .into_response() } /// Uploads a file with Write-Behind Cache + Dedup (smart strategy). @@ -118,13 +133,18 @@ impl FileHandler { if name == "folder_id" { let v = field.text().await.unwrap_or_default(); - if !v.is_empty() { folder_id = Some(v); } + if !v.is_empty() { + folder_id = Some(v); + } continue; } if name == "file" { let filename = field.file_name().unwrap_or("unnamed").to_string(); - let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); // Collect chunks let mut chunks: Vec = Vec::new(); @@ -138,7 +158,10 @@ impl FileHandler { // Empty file if chunks.is_empty() { let upload_svc = &state.applications.file_upload_service; - return match upload_svc.upload_file(filename, folder_id, content_type, vec![]).await { + return match upload_svc + .upload_file(filename, folder_id, content_type, vec![]) + .await + { Ok(file) => Self::created_json_response(&file).into_response(), Err(err) => Self::domain_error_response(err).into_response(), }; @@ -146,13 +169,22 @@ impl FileHandler { // Delegate to smart_upload (handles write-behind, dedup, streaming) match upload_service - .smart_upload(filename.clone(), folder_id, content_type, chunks, total_size) + .smart_upload( + filename.clone(), + folder_id, + content_type, + chunks, + total_size, + ) .await { Ok((file, strategy)) => { tracing::info!( "✅ SMART UPLOAD: {} ({} bytes, strategy: {:?}, ID: {})", - filename, total_size, strategy, file.id + filename, + total_size, + strategy, + file.id ); return Self::created_json_response(&file).into_response(); } @@ -164,9 +196,13 @@ impl FileHandler { } } - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "No file provided" - }))).into_response() + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "No file provided" + })), + ) + .into_response() } // ═══════════════════════════════════════════════════════════════════════ @@ -191,31 +227,46 @@ impl FileHandler { "preview" => ThumbnailSize::Preview, "large" => ThumbnailSize::Large, _ => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "Invalid thumbnail size. Use: icon, preview, or large" - }))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid thumbnail size. Use: icon, preview, or large" + })), + ) + .into_response(); } }; let file = match file_retrieval_service.get_file(&id).await { Ok(f) => f, Err(err) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("File not found: {}", err) - }))).into_response(); + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File not found: {}", err) + })), + ) + .into_response(); } }; if !thumbnail_service.is_supported_image(&file.mime_type) { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "File is not a supported image type" - }))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "File is not a supported image type" + })), + ) + .into_response(); } let storage_root = state.core.path_service.get_root_path(); let file_path = storage_root.join(&file.path); - match thumbnail_service.get_thumbnail(&id, thumb_size, &file_path).await { + match thumbnail_service + .get_thumbnail(&id, thumb_size, &file_path) + .await + { Ok(data) => { let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); Response::builder() @@ -230,9 +281,13 @@ impl FileHandler { } Err(err) => { tracing::error!("Thumbnail generation failed: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Failed to generate thumbnail: {}", err) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to generate thumbnail: {}", err) + })), + ) + .into_response() } } } @@ -259,29 +314,42 @@ impl FileHandler { let file_dto = match retrieval.get_file(&id).await { Ok(f) => f, Err(err) => { - let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") { + let status = if err.to_string().contains("not found") + || err.to_string().contains("NotFound") + { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; - return (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response(); + return ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response(); } }; // ── Metadata-only request ──────────────────────────────────── - if params.get("metadata").is_some_and(|v| v == "true" || v == "1") { - return (StatusCode::OK, Json(serde_json::json!({ - "id": file_dto.id, - "name": file_dto.name, - "path": file_dto.path, - "size": file_dto.size, - "mime_type": file_dto.mime_type, - "folder_id": file_dto.folder_id, - "created_at": file_dto.created_at, - "modified_at": file_dto.modified_at - }))).into_response(); + if params + .get("metadata") + .is_some_and(|v| v == "true" || v == "1") + { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "id": file_dto.id, + "name": file_dto.name, + "path": file_dto.path, + "size": file_dto.size, + "mime_type": file_dto.mime_type, + "folder_id": file_dto.folder_id, + "created_at": file_dto.created_at, + "modified_at": file_dto.modified_at + })), + ) + .into_response(); } let etag = format!("\"{}-{}\"", id, file_dto.modified_at); @@ -289,112 +357,137 @@ impl FileHandler { // ── ETag (304 Not Modified) ────────────────────────────────── if let Some(inm) = headers.get(header::IF_NONE_MATCH) && let Ok(client_etag) = inm.to_str() - && (client_etag == etag || client_etag == "*") { - return Response::builder() - .status(StatusCode::NOT_MODIFIED) - .header(header::ETAG, &etag) - .body(Body::empty()) - .unwrap() - .into_response(); - } + && (client_etag == etag || client_etag == "*") + { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .body(Body::empty()) + .unwrap() + .into_response(); + } // ── Range Requests ─────────────────────────────────────────── if let Some(range_header) = headers.get(header::RANGE) && let Ok(range_str) = range_header.to_str() - && let Ok(ranges) = parse_range_header(range_str) { - let validated = ranges.validate(file_dto.size); - if let Ok(valid_ranges) = validated { - if let Some(range) = valid_ranges.first() { - let start = *range.start(); - let end = *range.end(); - let range_length = end - start + 1; - let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); + && let Ok(ranges) = parse_range_header(range_str) + { + let validated = ranges.validate(file_dto.size); + if let Ok(valid_ranges) = validated { + if let Some(range) = valid_ranges.first() { + let start = *range.start(); + let end = *range.end(); + let range_length = end - start + 1; + let disposition = + Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); - match retrieval.get_file_range_stream(&id, start, Some(end + 1)).await { - Ok(stream) => { - return Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_TYPE, &file_dto.mime_type) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, range_length) - .header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_dto.size)) - .header(header::ACCEPT_RANGES, "bytes") - .header(header::ETAG, &etag) - .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") - .body(Body::from_stream(Box::into_pin(stream))) - .unwrap() - .into_response(); - } - Err(err) => { - tracing::error!("Error creating range stream: {}", err); - // fall through to normal download - } - } + match retrieval + .get_file_range_stream(&id, start, Some(end + 1)) + .await + { + Ok(stream) => { + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, &file_dto.mime_type) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, range_length) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, file_dto.size), + ) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .body(Body::from_stream(Box::into_pin(stream))) + .unwrap() + .into_response(); + } + Err(err) => { + tracing::error!("Error creating range stream: {}", err); + // fall through to normal download } - } else { - return Response::builder() - .status(StatusCode::RANGE_NOT_SATISFIABLE) - .header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size)) - .body(Body::empty()) - .unwrap() - .into_response(); } } + } else { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size)) + .body(Body::empty()) + .unwrap() + .into_response(); + } + } // ── Normal download (delegated to service) ─────────────────── let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); - let accept_webp = headers.get(header::ACCEPT) + let accept_webp = headers + .get(header::ACCEPT) .and_then(|v| v.to_str().ok()) .is_some_and(|a| a.contains("image/webp")); - let prefer_original = params.get("original").is_some_and(|v| v == "true" || v == "1"); + let prefer_original = params + .get("original") + .is_some_and(|v| v == "true" || v == "1"); - match retrieval.get_file_optimized(&id, accept_webp, prefer_original).await { + match retrieval + .get_file_optimized(&id, accept_webp, prefer_original) + .await + { Ok((_file, content)) => match content { - OptimizedFileContent::Bytes { data, mime_type, .. } => { - Self::build_cached_response( - data, - &mime_type, - &disposition, - &etag, - file_dto.size, - ¶ms, - &*state.core.compression_service, - ).await - .into_response() - } - OptimizedFileContent::Mmap(mmap_data) => { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &file_dto.mime_type) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, mmap_data.len()) - .header(header::ETAG, &etag) - .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") - .header(header::ACCEPT_RANGES, "bytes") - .body(Body::from(mmap_data)) - .unwrap() - .into_response() - } - OptimizedFileContent::Stream(pinned_stream) => { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &file_dto.mime_type) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, file_dto.size) - .header(header::ETAG, &etag) - .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") - .header(header::ACCEPT_RANGES, "bytes") - .body(Body::from_stream(pinned_stream)) - .unwrap() - .into_response() - } + OptimizedFileContent::Bytes { + data, mime_type, .. + } => Self::build_cached_response( + data, + &mime_type, + &disposition, + &etag, + file_dto.size, + ¶ms, + &*state.core.compression_service, + ) + .await + .into_response(), + OptimizedFileContent::Mmap(mmap_data) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &file_dto.mime_type) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, mmap_data.len()) + .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::from(mmap_data)) + .unwrap() + .into_response(), + OptimizedFileContent::Stream(pinned_stream) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &file_dto.mime_type) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, file_dto.size) + .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::from_stream(pinned_stream)) + .unwrap() + .into_response(), }, Err(err) => { tracing::error!("Error downloading file: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error reading file: {}", err) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Error reading file: {}", err) + })), + ) + .into_response() } } } @@ -421,9 +514,13 @@ impl FileHandler { } Err(err) => { tracing::error!("Error listing files: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error listing files: {}", err) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Error listing files: {}", err) + })), + ) + .into_response() } } } @@ -440,39 +537,38 @@ impl FileHandler { let response = Self::upload_file_with_cache(State(state.clone()), multipart).await; // Try to extract file info for thumbnail generation - if let Ok(body_bytes) = axum::body::to_bytes( - response.into_response().into_body(), - 10 * 1024, - ).await + if let Ok(body_bytes) = + axum::body::to_bytes(response.into_response().into_body(), 10 * 1024).await && let Ok(file_info) = serde_json::from_slice::(&body_bytes) - && let (Some(file_id), Some(mime_type), Some(file_path_str)) = ( - file_info.get("id").and_then(|v| v.as_str()), - file_info.get("mime_type").and_then(|v| v.as_str()), - file_info.get("path").and_then(|v| v.as_str()), - ) { - // Generate thumbnails for images in background - if state.core.thumbnail_service.is_supported_image(mime_type) { - let file_id = file_id.to_string(); - let file_path_rel = file_path_str.to_string(); - let thumbnail_service = state.core.thumbnail_service.clone(); - let path_service = state.core.path_service.clone(); + && let (Some(file_id), Some(mime_type), Some(file_path_str)) = ( + file_info.get("id").and_then(|v| v.as_str()), + file_info.get("mime_type").and_then(|v| v.as_str()), + file_info.get("path").and_then(|v| v.as_str()), + ) + { + // Generate thumbnails for images in background + if state.core.thumbnail_service.is_supported_image(mime_type) { + let file_id = file_id.to_string(); + let file_path_rel = file_path_str.to_string(); + let thumbnail_service = state.core.thumbnail_service.clone(); + let path_service = state.core.path_service.clone(); - tokio::spawn(async move { - let file_path = path_service.get_root_path().join(&file_path_rel); - tracing::info!("🖼️ Generating thumbnails for: {}", file_id); - thumbnail_service.generate_all_sizes_background(file_id, file_path); - }); - } + tokio::spawn(async move { + let file_path = path_service.get_root_path().join(&file_path_rel); + tracing::info!("🖼️ Generating thumbnails for: {}", file_id); + thumbnail_service.generate_all_sizes_background(file_id, file_path); + }); + } - // Return the response - return Response::builder() - .status(StatusCode::CREATED) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") - .body(Body::from(body_bytes)) - .unwrap() - .into_response(); - } + // Return the response + return Response::builder() + .status(StatusCode::CREATED) + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(Body::from(body_bytes)) + .unwrap() + .into_response(); + } // Fallback for errors (StatusCode::INTERNAL_SERVER_ERROR, "Upload processing error").into_response() @@ -499,9 +595,13 @@ impl FileHandler { } Err(err) => { tracing::error!("Error listing files: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } @@ -526,13 +626,15 @@ impl FileHandler { let result = if let Some(uid) = user_id { // Auth available: trash-first with dedup cleanup - mgmt.delete_with_cleanup(&id, &uid).await.map(|was_trashed| { - if was_trashed { - tracing::info!("File moved to trash: {}", id); - } else { - tracing::info!("File permanently deleted: {}", id); - } - }) + mgmt.delete_with_cleanup(&id, &uid) + .await + .map(|was_trashed| { + if was_trashed { + tracing::info!("File moved to trash: {}", id); + } else { + tracing::info!("File permanently deleted: {}", id); + } + }) } else { // No auth: permanent delete tracing::warn!("No auth context – permanently deleting file: {}", id); @@ -545,14 +647,20 @@ impl FileHandler { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(err) => { tracing::error!("Error deleting file: {}", err); - let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") { + let status = if err.to_string().contains("not found") + || err.to_string().contains("NotFound") + { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; - (status, Json(serde_json::json!({ - "error": format!("Error deleting file: {}", err) - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": format!("Error deleting file: {}", err) + })), + ) + .into_response() } } } @@ -570,9 +678,13 @@ impl FileHandler { let new_name = match payload.get("name").and_then(|v| v.as_str()) { Some(name) if !name.trim().is_empty() => name.trim().to_string(), _ => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "Missing or empty 'name' field" - }))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Missing or empty 'name' field" + })), + ) + .into_response(); } }; @@ -582,16 +694,22 @@ impl FileHandler { Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), Err(err) => { tracing::error!("Error renaming file: {}", err); - let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") { + let status = if err.to_string().contains("not found") + || err.to_string().contains("NotFound") + { StatusCode::NOT_FOUND } else if err.to_string().contains("already exists") { StatusCode::CONFLICT } else { StatusCode::INTERNAL_SERVER_ERROR }; - (status, Json(serde_json::json!({ - "error": format!("Error renaming file: {}", err) - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": format!("Error renaming file: {}", err) + })), + ) + .into_response() } } } @@ -608,22 +726,28 @@ impl FileHandler { let mgmt = &state.applications.file_management_service; match retrieval.get_file(&id).await { - Ok(_) => { - match mgmt.move_file(&id, payload.folder_id).await { - Ok(file) => (StatusCode::OK, Json(file)).into_response(), - Err(err) => { - tracing::error!("Error moving file: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + Ok(_) => match mgmt.move_file(&id, payload.folder_id).await { + Ok(file) => (StatusCode::OK, Json(file)).into_response(), + Err(err) => { + tracing::error!("Error moving file: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Error moving file: {}", err) - }))).into_response() - } + })), + ) + .into_response() } - } + }, Err(err) => { tracing::error!("File not found for move: {}", err); - (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("File with ID {} does not exist", id) - }))).into_response() + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File with ID {} does not exist", id) + })), + ) + .into_response() } } } @@ -644,9 +768,13 @@ impl FileHandler { Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), Err(err) => { tracing::error!("Error moving file: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error moving file: {}", err) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Error moving file: {}", err) + })), + ) + .into_response() } } } @@ -670,7 +798,9 @@ impl FileHandler { /// Build a Content-Disposition header value. fn content_disposition(name: &str, mime: &str, params: &HashMap) -> String { - let force_inline = params.get("inline").is_some_and(|v| v == "true" || v == "1"); + let force_inline = params + .get("inline") + .is_some_and(|v| v == "true" || v == "1"); if force_inline || mime.starts_with("image/") || mime == "application/pdf" @@ -720,7 +850,8 @@ impl FileHandler { ) -> Response { let compression_param = params.get("compress").map(|v| v.as_str()); let force_compress = compression_param == Some("true") || compression_param == Some("1"); - let force_no_compress = compression_param == Some("false") || compression_param == Some("0"); + let force_no_compress = + compression_param == Some("false") || compression_param == Some("0"); let should_compress = if force_no_compress { false @@ -740,26 +871,28 @@ impl FileHandler { .status(StatusCode::OK) .header(header::CONTENT_DISPOSITION, disposition) .header(header::ETAG, etag) - .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) .header(header::VARY, "Accept-Encoding"); if should_compress { - match compression_service.compress_data(&content, compression_level).await { - Ok(compressed) => { - builder - .header(header::CONTENT_TYPE, mime_type) - .header(header::CONTENT_ENCODING, "gzip") - .header(header::CONTENT_LENGTH, compressed.len()) - .body(Body::from(compressed)) - .unwrap() - } - Err(_) => { - builder - .header(header::CONTENT_TYPE, mime_type) - .header(header::CONTENT_LENGTH, content.len()) - .body(Body::from(content)) - .unwrap() - } + match compression_service + .compress_data(&content, compression_level) + .await + { + Ok(compressed) => builder + .header(header::CONTENT_TYPE, mime_type) + .header(header::CONTENT_ENCODING, "gzip") + .header(header::CONTENT_LENGTH, compressed.len()) + .body(Body::from(compressed)) + .unwrap(), + Err(_) => builder + .header(header::CONTENT_TYPE, mime_type) + .header(header::CONTENT_LENGTH, content.len()) + .body(Body::from(content)) + .unwrap(), } } else { builder @@ -776,4 +909,4 @@ impl FileHandler { pub struct MoveFilePayload { /// Target folder ID (None means root) pub folder_id: Option, -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 03a24570..8d9085af 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -1,19 +1,19 @@ -use std::sync::Arc; -use std::collections::HashMap; use axum::{ - extract::{Path, State, Query}, - http::{StatusCode, header, HeaderName, HeaderValue, Response}, - response::IntoResponse, Json, + extract::{Path, Query, State}, + http::{HeaderName, HeaderValue, Response, StatusCode, header}, + response::IntoResponse, }; +use std::collections::HashMap; +use std::sync::Arc; -use crate::application::services::folder_service::FolderService; -use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto}; +use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; use crate::application::dtos::pagination::PaginationRequestDto; -use crate::common::errors::ErrorKind; use crate::application::ports::inbound::FolderUseCase; +use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; -use crate::interfaces::middleware::auth::{OptionalAuthUser, AuthUser}; +use crate::common::errors::ErrorKind; +use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; type AppState = Arc; @@ -34,12 +34,12 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Gets a folder by ID pub async fn get_folder( State(service): State, @@ -52,12 +52,12 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Lists root folders (no parent ID) /// Non-admin users only see their own home folder. pub async fn list_root_folders( @@ -111,18 +111,20 @@ impl FolderHandler { parent_id: Option<&str>, ) -> axum::response::Response { match service.list_folders(parent_id).await { - Ok(folders) => { - (StatusCode::OK, Json(folders)).into_response() - }, + Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } @@ -138,35 +140,42 @@ impl FolderHandler { Ok(folders) => { // Only filter at root level (parent_id == None) let filtered = if parent_id.is_none() { - folders.into_iter().filter(|f| { - // Skip hidden/system folders - if f.name.starts_with('.') { - return false; - } - // If it's a user home folder, only show if it belongs to this user - if Self::is_user_home_folder(&f.name) { - return Self::folder_belongs_to_user(&f.name, &auth_user.username); - } - // Non-home folders are visible to everyone - true - }).collect() + folders + .into_iter() + .filter(|f| { + // Skip hidden/system folders + if f.name.starts_with('.') { + return false; + } + // If it's a user home folder, only show if it belongs to this user + if Self::is_user_home_folder(&f.name) { + return Self::folder_belongs_to_user(&f.name, &auth_user.username); + } + // Non-home folders are visible to everyone + true + }) + .collect() } else { folders }; (StatusCode::OK, Json(filtered)).into_response() - }, + } Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } - + /// Lists folders with pagination support (internal helper) async fn list_folders_paginated_inner( service: AppState, @@ -174,23 +183,25 @@ impl FolderHandler { parent_id: Option<&str>, ) -> axum::response::Response { match service.list_folders_paginated(parent_id, &pagination).await { - Ok(paginated_result) => { - (StatusCode::OK, Json(paginated_result)).into_response() - }, + Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + // Return a JSON error response - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } - + /// Renames a folder pub async fn rename_folder( State(service): State, @@ -205,15 +216,19 @@ impl FolderHandler { ErrorKind::AlreadyExists => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + // Return a proper JSON error response - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } - + /// Moves a folder to a new parent pub async fn move_folder( State(service): State, @@ -228,12 +243,12 @@ impl FolderHandler { ErrorKind::AlreadyExists => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Deletes a folder (with trash support) pub async fn delete_folder( State(service): State, @@ -247,58 +262,68 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Deletes a folder with trash functionality pub async fn delete_folder_with_trash( State(state): State, OptionalAuthUser(auth_user): OptionalAuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous"); + let user_id = auth_user + .as_ref() + .map(|u| u.id.as_str()) + .unwrap_or("anonymous"); // Check if trash service is available if let Some(trash_service) = &state.trash_service { tracing::info!("Moving folder to trash: {}", id); - + // Try to move to trash first match trash_service.move_to_trash(&id, "folder", user_id).await { Ok(_) => { tracing::info!("Folder successfully moved to trash: {}", id); return StatusCode::NO_CONTENT.into_response(); - }, + } Err(err) => { - tracing::warn!("Could not move folder to trash, falling back to permanent delete: {}", err); + tracing::warn!( + "Could not move folder to trash, falling back to permanent delete: {}", + err + ); // Fall through to regular delete if trash fails } } } - + // Fallback to permanent delete if trash is unavailable or failed let folder_service = &state.applications.folder_service; match folder_service.delete_folder(&id).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() - }, + } Err(err) => { tracing::error!("Error deleting folder: {}", err); - + let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - (status, Json(serde_json::json!({ - "error": format!("Error deleting folder: {}", err) - }))).into_response() + + ( + status, + Json(serde_json::json!({ + "error": format!("Error deleting folder: {}", err) + })), + ) + .into_response() } } } - + /// Downloads a folder as a ZIP file pub async fn download_folder_zip( State(state): State, @@ -306,67 +331,85 @@ impl FolderHandler { Query(_params): Query>, ) -> impl IntoResponse { tracing::info!("Downloading folder as ZIP: {}", id); - + // Get folder information first to check it exists and get name let folder_service = &state.applications.folder_service; - + match folder_service.get_folder(&id).await { Ok(folder) => { tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); - + // Use ZIP service from DI container let zip_service = &state.core.zip_service; - + // Create the ZIP file match zip_service.create_folder_zip(&id, &folder.name).await { Ok(zip_data) => { - tracing::info!("ZIP file created successfully, size: {} bytes", zip_data.len()); - + tracing::info!( + "ZIP file created successfully, size: {} bytes", + zip_data.len() + ); + // Setup headers for download let filename = format!("{}.zip", folder.name); let content_disposition = format!("attachment; filename=\"{}\"", filename); - + // Build response with the ZIP data let mut headers = HashMap::new(); - headers.insert(header::CONTENT_TYPE.to_string(), "application/zip".to_string()); - headers.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); - headers.insert(header::CONTENT_LENGTH.to_string(), zip_data.len().to_string()); - + headers.insert( + header::CONTENT_TYPE.to_string(), + "application/zip".to_string(), + ); + headers + .insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); + headers.insert( + header::CONTENT_LENGTH.to_string(), + zip_data.len().to_string(), + ); + // Build the response let mut response = Response::builder() .status(StatusCode::OK) .body(axum::body::Body::from(zip_data)) .unwrap(); - + // Add headers to response for (name, value) in headers { response.headers_mut().insert( HeaderName::from_bytes(name.as_bytes()).unwrap(), - HeaderValue::from_str(&value).unwrap() + HeaderValue::from_str(&value).unwrap(), ); } - + response - }, + } Err(err) => { tracing::error!("Error creating ZIP file: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error creating ZIP file: {}", err) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Error creating ZIP file: {}", err) + })), + ) + .into_response() } } - }, + } Err(err) => { tracing::error!("Folder not found: {}", err); let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - (status, Json(serde_json::json!({ - "error": format!("Error finding folder: {}", err) - }))).into_response() + + ( + status, + Json(serde_json::json!({ + "error": format!("Error finding folder: {}", err) + })), + ) + .into_response() } } } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/i18n_handler.rs b/src/interfaces/api/handlers/i18n_handler.rs index 775fded6..72b7dd69 100644 --- a/src/interfaces/api/handlers/i18n_handler.rs +++ b/src/interfaces/api/handlers/i18n_handler.rs @@ -1,14 +1,16 @@ -use std::sync::Arc; use axum::{ - extract::{State, Query, Path}, + Json, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - Json, }; +use std::sync::Arc; +use crate::application::dtos::i18n_dto::{ + LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto, +}; use crate::application::services::i18n_application_service::I18nApplicationService; -use crate::application::dtos::i18n_dto::{LocaleDto, TranslationRequestDto, TranslationResponseDto, TranslationErrorDto}; -use crate::domain::services::i18n_service::{Locale, I18nError}; +use crate::domain::services::i18n_service::{I18nError, Locale}; type AppState = Arc; @@ -17,37 +19,33 @@ pub struct I18nHandler; impl I18nHandler { /// Gets a list of available locales - pub async fn get_locales( - State(service): State, - ) -> impl IntoResponse { + pub async fn get_locales(State(service): State) -> impl IntoResponse { let locales = service.available_locales().await; let locale_dtos: Vec = locales.into_iter().map(LocaleDto::from).collect(); - + (StatusCode::OK, Json(locale_dtos)).into_response() } - + /// Translates a key to the requested locale pub async fn translate( State(service): State, Query(query): Query, ) -> impl IntoResponse { let locale = match &query.locale { - Some(locale_str) => { - match Locale::from_str(locale_str) { - Some(locale) => Some(locale), - None => { - let error = TranslationErrorDto { - key: query.key.clone(), - locale: locale_str.clone(), - error: format!("Unsupported locale: {}", locale_str), - }; - return (StatusCode::BAD_REQUEST, Json(error)).into_response(); - } + Some(locale_str) => match Locale::from_str(locale_str) { + Some(locale) => Some(locale), + None => { + let error = TranslationErrorDto { + key: query.key.clone(), + locale: locale_str.clone(), + error: format!("Unsupported locale: {}", locale_str), + }; + return (StatusCode::BAD_REQUEST, Json(error)).into_response(); } }, None => None, }; - + match service.translate(&query.key, locale).await { Ok(text) => { let response = TranslationResponseDto { @@ -56,25 +54,25 @@ impl I18nHandler { text, }; (StatusCode::OK, Json(response)).into_response() - }, + } Err(err) => { let status = match &err { I18nError::KeyNotFound(_) => StatusCode::NOT_FOUND, I18nError::InvalidLocale(_) => StatusCode::BAD_REQUEST, I18nError::LoadError(_) => StatusCode::INTERNAL_SERVER_ERROR, }; - + let error = TranslationErrorDto { key: query.key, locale: locale.unwrap_or(Locale::default()).as_str().to_string(), error: err.to_string(), }; - + (status, Json(error)).into_response() } } } - + /// Gets all translations for a locale (Axum-compatible: extracts locale from path) pub async fn get_translations_by_locale( State(service): State, @@ -91,16 +89,24 @@ impl I18nHandler { let locale = match Locale::from_str(&locale_code) { Some(locale) => locale, None => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": format!("Unsupported locale: {}", locale_code) - }))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!("Unsupported locale: {}", locale_code) + })), + ) + .into_response(); } }; - + // This implementation is a bit weird, as we don't have a way to get all translations // We should improve the I18nService to support this - (StatusCode::OK, Json(serde_json::json!({ - "locale": locale.as_str() - }))).into_response() + ( + StatusCode::OK, + Json(serde_json::json!({ + "locale": locale.as_str() + })), + ) + .into_response() } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 337d2778..52f0e1d0 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -1,19 +1,19 @@ pub mod admin_handler; -pub mod file_handler; -pub mod folder_handler; -pub mod i18n_handler; -pub mod batch_handler; pub mod auth_handler; -pub mod trash_handler; -pub mod search_handler; -pub mod share_handler; -pub mod favorites_handler; -pub mod recent_handler; -pub mod webdav_handler; +pub mod batch_handler; pub mod caldav_handler; pub mod carddav_handler; pub mod chunked_upload_handler; pub mod dedup_handler; +pub mod favorites_handler; +pub mod file_handler; +pub mod folder_handler; +pub mod i18n_handler; +pub mod recent_handler; +pub mod search_handler; +pub mod share_handler; +pub mod trash_handler; +pub mod webdav_handler; /// Tipo de resultado para controladores de API -pub type ApiResult = Result; \ No newline at end of file +pub type ApiResult = Result; diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 4629ddc7..96caf6a6 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -1,11 +1,11 @@ -use std::sync::Arc; use axum::{ - extract::{Path, State, Query}, + Json, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - Json, }; use serde::Deserialize; +use std::sync::Arc; use tracing::{error, info}; use crate::application::ports::recent_ports::RecentItemsUseCase; @@ -25,20 +25,21 @@ pub async fn get_recent_items( Query(params): Query, ) -> impl IntoResponse { let user_id = &auth_user.id; - + match recent_service.get_recent_items(user_id, params.limit).await { Ok(items) => { info!("Retrieved {} recent items for user", items.len()); (StatusCode::OK, Json(items)).into_response() - }, + } Err(err) => { error!("Error retrieving recent items: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to retrieve recent items: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } @@ -50,35 +51,41 @@ pub async fn record_item_access( Path((item_type, item_id)): Path<(String, String)>, ) -> impl IntoResponse { let user_id = &auth_user.id; - + // Validate item type if item_type != "file" && item_type != "folder" { return ( - StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Item type must be 'file' or 'folder'" - })) - ).into_response(); + })), + ) + .into_response(); } - - match recent_service.record_item_access(user_id, &item_id, &item_type).await { + + match recent_service + .record_item_access(user_id, &item_id, &item_type) + .await + { Ok(_) => { info!("Recorded access to {} '{}' in recents", item_type, item_id); ( - StatusCode::OK, + StatusCode::OK, Json(serde_json::json!({ "message": "Access recorded successfully" - })) - ).into_response() - }, + })), + ) + .into_response() + } Err(err) => { error!("Error recording access in recents: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to record access: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } @@ -90,35 +97,41 @@ pub async fn remove_from_recent( Path((item_type, item_id)): Path<(String, String)>, ) -> impl IntoResponse { let user_id = &auth_user.id; - - match recent_service.remove_from_recent(user_id, &item_id, &item_type).await { + + match recent_service + .remove_from_recent(user_id, &item_id, &item_type) + .await + { Ok(removed) => { if removed { info!("Removed {} '{}' from recents", item_type, item_id); ( - StatusCode::OK, + StatusCode::OK, Json(serde_json::json!({ "message": "Item removed from recents" - })) - ).into_response() + })), + ) + .into_response() } else { info!("Item {} '{}' was not in recents", item_type, item_id); ( - StatusCode::NOT_FOUND, + StatusCode::NOT_FOUND, Json(serde_json::json!({ "message": "Item was not in recents" - })) - ).into_response() + })), + ) + .into_response() } - }, + } Err(err) => { error!("Error removing from recents: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to remove from recents: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } @@ -129,25 +142,27 @@ pub async fn clear_recent_items( auth_user: AuthUser, ) -> impl IntoResponse { let user_id = &auth_user.id; - + match recent_service.clear_recent_items(user_id).await { Ok(_) => { info!("Cleared all recent items for user"); ( - StatusCode::OK, + StatusCode::OK, Json(serde_json::json!({ "message": "Recent items cleared successfully" - })) - ).into_response() - }, + })), + ) + .into_response() + } Err(err) => { error!("Error clearing recent items: {}", err); ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Failed to clear recent items: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 802c494c..4d5b6eca 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -1,17 +1,17 @@ use axum::{ - extract::{State, Query, Json}, - response::IntoResponse, + extract::{Json, Query, State}, http::StatusCode, + response::IntoResponse, }; use serde_json::json; -use tracing::{info, error}; +use tracing::{error, info}; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::common::di::AppState; /** * Handler for search operations through the API. - * + * * This handler exposes endpoints related to search functionality, * allowing users to search for files and folders using various criteria. */ @@ -20,9 +20,9 @@ pub struct SearchHandler; impl SearchHandler { /** * Performs a search based on the criteria provided as query parameters. - * + * * This endpoint allows simple searches directly with URL parameters. - * + * * @param state Application state with services * @param query_params Search parameters as query string * @return HTTP response with the search results @@ -32,7 +32,7 @@ impl SearchHandler { Query(params): Query, ) -> impl IntoResponse { info!("API: File search with parameters: {:?}", params); - + // Extract the search service or return error if not available let search_service = match &state.applications.search_service { Some(service) => service, @@ -42,15 +42,18 @@ impl SearchHandler { StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "Search service is not available" - })) - ).into_response(); + })), + ) + .into_response(); } }; - + // Convert search parameters to DTO let search_criteria = SearchCriteriaDto { name_contains: params.query, - file_types: params.type_filter.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()), + file_types: params + .type_filter + .map(|t| t.split(',').map(|s| s.trim().to_string()).collect()), created_after: params.created_after, created_before: params.created_before, modified_after: params.modified_after, @@ -62,32 +65,36 @@ impl SearchHandler { limit: params.limit.unwrap_or(100), offset: params.offset.unwrap_or(0), }; - + // Perform the search match search_service.search(search_criteria).await { Ok(results) => { - info!("Search completed, {} files and {} folders found", - results.files.len(), results.folders.len()); + info!( + "Search completed, {} files and {} folders found", + results.files.len(), + results.folders.len() + ); (StatusCode::OK, Json(results)).into_response() - }, + } Err(err) => { error!("Search error: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Search error: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } - + /** * Performs an advanced search based on a complete JSON criteria object. - * + * * This endpoint allows more complex searches with all possible criteria * provided in the request body. - * + * * @param state Application state with services * @param criteria Complete search criteria * @return HTTP response with the search results @@ -97,7 +104,7 @@ impl SearchHandler { Json(criteria): Json, ) -> impl IntoResponse { info!("API: Advanced file search"); - + // Extract the search service or return error if not available let search_service = match &state.applications.search_service { Some(service) => service, @@ -107,44 +114,47 @@ impl SearchHandler { StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "Search service is not available" - })) - ).into_response(); + })), + ) + .into_response(); } }; - + // Perform the search match search_service.search(criteria).await { Ok(results) => { - info!("Search completed, {} files and {} folders found", - results.files.len(), results.folders.len()); + info!( + "Search completed, {} files and {} folders found", + results.files.len(), + results.folders.len() + ); (StatusCode::OK, Json(results)).into_response() - }, + } Err(err) => { error!("Search error: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Search error: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } - + /** * Clears the search results cache. - * + * * This endpoint is useful for forcing fresh searches after significant * changes in the file system. - * + * * @param state Application state with services * @return HTTP response indicating success or error */ - pub async fn clear_search_cache( - State(state): State, - ) -> impl IntoResponse { + pub async fn clear_search_cache(State(state): State) -> impl IntoResponse { info!("API: Clearing search cache"); - + // Extract the search service or return error if not available let search_service = match &state.applications.search_service { Some(service) => service, @@ -154,11 +164,12 @@ impl SearchHandler { StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "Search service is not available" - })) - ).into_response(); + })), + ) + .into_response(); } }; - + // Clear the cache match search_service.clear_search_cache().await { Ok(_) => { @@ -167,17 +178,19 @@ impl SearchHandler { StatusCode::OK, Json(json!({ "message": "Search cache cleared successfully" - })) - ).into_response() - }, + })), + ) + .into_response() + } Err(err) => { error!("Error clearing search cache: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Error clearing search cache: {}", err) - })) - ).into_response() + })), + ) + .into_response() } } } @@ -188,38 +201,38 @@ impl SearchHandler { pub struct SearchParams { /// Text to search for in file and folder names pub query: Option, - + /// Filter by file types (comma-separated extensions) #[serde(rename = "type")] pub type_filter: Option, - + /// Filter items created after this date (timestamp) pub created_after: Option, - + /// Filter items created before this date (timestamp) pub created_before: Option, - + /// Filter items modified after this date (timestamp) pub modified_after: Option, - + /// Filter items modified before this date (timestamp) pub modified_before: Option, - + /// Minimum size in bytes pub min_size: Option, - + /// Maximum size in bytes pub max_size: Option, - + /// Folder ID to limit the search scope pub folder_id: Option, - + /// Recursive search in subfolders pub recursive: Option, - + /// Result limit for pagination pub limit: Option, - + /// Offset for pagination pub offset: Option, -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index e25597a3..0bdcb8e3 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -1,18 +1,18 @@ use std::sync::Arc; use axum::{ + Json, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - Json, }; use serde::Deserialize; use serde_json::json; use crate::{ application::{ - dtos::share_dto::{CreateShareDto, UpdateShareDto}, - ports::share_ports::ShareUseCase + dtos::share_dto::{CreateShareDto, UpdateShareDto}, + ports::share_ports::ShareUseCase, }, common::errors::ErrorKind, interfaces::middleware::auth::OptionalAuthUser, @@ -35,7 +35,10 @@ pub async fn create_shared_link( auth_user: OptionalAuthUser, Json(dto): Json, ) -> impl IntoResponse { - let user_id = auth_user.0.map(|u| u.id).unwrap_or_else(|| "anonymous".to_string()); + let user_id = auth_user + .0 + .map(|u| u.id) + .unwrap_or_else(|| "anonymous".to_string()); match share_use_case.create_shared_link(&user_id, dto).await { Ok(share) => (StatusCode::CREATED, Json(share)).into_response(), Err(err) => { @@ -72,13 +75,23 @@ pub async fn get_user_shares( auth_user: OptionalAuthUser, Query(query): Query, ) -> impl IntoResponse { - let user_id = auth_user.0.map(|u| u.id).unwrap_or_else(|| "anonymous".to_string()); + let user_id = auth_user + .0 + .map(|u| u.id) + .unwrap_or_else(|| "anonymous".to_string()); let page = query.page.unwrap_or(1); let per_page = query.per_page.unwrap_or(20); - - match share_use_case.get_user_shared_links(&user_id, page, per_page).await { + + match share_use_case + .get_user_shared_links(&user_id, page, per_page) + .await + { Ok(shares) => (StatusCode::OK, Json(shares)).into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() }))).into_response() + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": err.to_string() })), + ) + .into_response(), } } @@ -127,7 +140,7 @@ pub async fn access_shared_item( ) -> impl IntoResponse { // Register the access let _ = share_use_case.register_shared_link_access(&token).await; - + // Get the shared link match share_use_case.get_shared_link_by_token(&token).await { Ok(item) => (StatusCode::OK, Json(item)).into_response(), @@ -138,17 +151,21 @@ pub async fn access_shared_item( if err.message.contains("expired") { StatusCode::GONE // HTTP 410 Gone for expired links } else if err.message.contains("password") { - return (StatusCode::UNAUTHORIZED, Json(json!({ - "error": "Password required", - "requiresPassword": true - }))).into_response(); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "Password required", + "requiresPassword": true + })), + ) + .into_response(); } else { StatusCode::FORBIDDEN } - }, + } _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, Json(json!({ "error": err.to_string() }))).into_response() } } @@ -160,7 +177,10 @@ pub async fn verify_shared_item_password( Path(token): Path, Json(req): Json, ) -> impl IntoResponse { - match share_use_case.verify_shared_link_password(&token, &req.password).await { + match share_use_case + .verify_shared_link_password(&token, &req.password) + .await + { Ok(item) => (StatusCode::OK, Json(item)).into_response(), Err(err) => { let status = match err.kind { @@ -173,10 +193,10 @@ pub async fn verify_shared_item_password( } else { StatusCode::FORBIDDEN } - }, + } _ => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(json!({ "error": err.to_string() }))).into_response() } } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/trash_handler.rs b/src/interfaces/api/handlers/trash_handler.rs index 7fdf0b30..136e68e7 100644 --- a/src/interfaces/api/handlers/trash_handler.rs +++ b/src/interfaces/api/handlers/trash_handler.rs @@ -1,8 +1,8 @@ +use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::Json; use serde_json::json; -use tracing::{debug, error, warn, instrument}; +use tracing::{debug, error, instrument, warn}; // use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; @@ -20,28 +20,34 @@ pub async fn get_trash_items( let effective_user = auth_user.id.clone(); debug!("Request to list trash items for user {}", effective_user); - + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; - + let result = trash_service.get_trash_items(&effective_user).await; - + match result { Ok(items) => { debug!("Found {} items in trash", items.len()); (StatusCode::OK, Json(json!(items))) - }, + } Err(e) => { error!("Error retrieving trash items: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error retrieving trash items: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error retrieving trash items: {}", e) + })), + ) } } } @@ -53,33 +59,49 @@ pub async fn move_to_trash( OptionalAuthUser(auth_user): OptionalAuthUser, Path((item_type, item_id)): Path<(String, String)>, ) -> (StatusCode, Json) { - let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous"); - debug!("Request to move to trash: type={}, id={}, user={}", - item_type, item_id, user_id); - + let user_id = auth_user + .as_ref() + .map(|u| u.id.as_str()) + .unwrap_or("anonymous"); + debug!( + "Request to move to trash: type={}, id={}, user={}", + item_type, item_id, user_id + ); + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; - let result = trash_service.move_to_trash(&item_id, &item_type, user_id).await; - + let result = trash_service + .move_to_trash(&item_id, &item_type, user_id) + .await; + match result { Ok(_) => { debug!("Item moved to trash successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item moved to trash successfully" - }))) - }, + ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Item moved to trash successfully" + })), + ) + } Err(e) => { error!("Error moving item to trash: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error moving item to trash: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error moving item to trash: {}", e) + })), + ) } } } @@ -91,35 +113,49 @@ pub async fn move_file_to_trash( OptionalAuthUser(auth_user): OptionalAuthUser, Path(item_id): Path, ) -> (StatusCode, Json) { - let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous"); - debug!("Request to move file to trash: id={}, user={}", - item_id, user_id); - + let user_id = auth_user + .as_ref() + .map(|u| u.id.as_str()) + .unwrap_or("anonymous"); + debug!( + "Request to move file to trash: id={}, user={}", + item_id, user_id + ); + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; - + // Specify that it is a file let result = trash_service.move_to_trash(&item_id, "file", user_id).await; - + match result { Ok(_) => { debug!("File moved to trash successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "File moved to trash successfully" - }))) - }, + ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "File moved to trash successfully" + })), + ) + } Err(e) => { error!("Error moving file to trash: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error moving file to trash: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error moving file to trash: {}", e) + })), + ) } } } @@ -131,35 +167,51 @@ pub async fn move_folder_to_trash( OptionalAuthUser(auth_user): OptionalAuthUser, Path(item_id): Path, ) -> (StatusCode, Json) { - let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous"); - debug!("Request to move folder to trash: id={}, user={}", - item_id, user_id); - + let user_id = auth_user + .as_ref() + .map(|u| u.id.as_str()) + .unwrap_or("anonymous"); + debug!( + "Request to move folder to trash: id={}, user={}", + item_id, user_id + ); + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; - + // Specify that it is a folder - let result = trash_service.move_to_trash(&item_id, "folder", user_id).await; - + let result = trash_service + .move_to_trash(&item_id, "folder", user_id) + .await; + match result { Ok(_) => { debug!("Folder moved to trash successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Folder moved to trash successfully" - }))) - }, + ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Folder moved to trash successfully" + })), + ) + } Err(e) => { error!("Error moving folder to trash: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error moving folder to trash: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error moving folder to trash: {}", e) + })), + ) } } } @@ -172,40 +224,55 @@ pub async fn restore_from_trash( Path(trash_id): Path, ) -> (StatusCode, Json) { debug!("Request to restore item {} from trash", trash_id); - + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; let result = trash_service.restore_item(&trash_id, &auth_user.id).await; - + match result { Ok(_) => { debug!("Item restored successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item restored successfully" - }))) - }, + ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Item restored successfully" + })), + ) + } Err(e) => { let err_str = format!("{}", e); // If item not found, report success (it was already restored or removed) if err_str.contains("not found") || err_str.contains("NotFound") { - warn!("Item not found in trash, but reporting success: {}", trash_id); - return (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item restored (or was already removed from trash)" - }))); + warn!( + "Item not found in trash, but reporting success: {}", + trash_id + ); + return ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Item restored (or was already removed from trash)" + })), + ); } error!("Error restoring item from trash: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error restoring item from trash: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error restoring item from trash: {}", e) + })), + ) } } } @@ -218,40 +285,57 @@ pub async fn delete_permanently( Path(trash_id): Path, ) -> (StatusCode, Json) { debug!("Request to permanently delete item {}", trash_id); - + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; - let result = trash_service.delete_permanently(&trash_id, &auth_user.id).await; - + let result = trash_service + .delete_permanently(&trash_id, &auth_user.id) + .await; + match result { Ok(_) => { debug!("Item permanently deleted"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item deleted permanently" - }))) - }, + ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Item deleted permanently" + })), + ) + } Err(e) => { let err_str = format!("{}", e); // If item not found, report success (it was already deleted) if err_str.contains("not found") || err_str.contains("NotFound") { - warn!("Item not found in trash, but reporting success: {}", trash_id); - return (StatusCode::OK, Json(json!({ - "success": true, - "message": "Item deleted (or was already removed from trash)" - }))); + warn!( + "Item not found in trash, but reporting success: {}", + trash_id + ); + return ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Item deleted (or was already removed from trash)" + })), + ); } error!("Error permanently deleting item: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error deleting item permanently: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error deleting item permanently: {}", e) + })), + ) } } } @@ -263,30 +347,39 @@ pub async fn empty_trash( auth_user: AuthUser, ) -> (StatusCode, Json) { debug!("Request to empty trash for user {}", auth_user.id); - + let trash_service = match state.trash_service.as_ref() { Some(service) => service, None => { - return (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Trash feature is not enabled" - }))); + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": "Trash feature is not enabled" + })), + ); } }; let result = trash_service.empty_trash(&auth_user.id).await; - + match result { Ok(_) => { debug!("Trash emptied successfully"); - (StatusCode::OK, Json(json!({ - "success": true, - "message": "Trash emptied successfully" - }))) - }, + ( + StatusCode::OK, + Json(json!({ + "success": true, + "message": "Trash emptied successfully" + })), + ) + } Err(e) => { error!("Error emptying trash: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Error emptying trash: {}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error emptying trash: {}", e) + })), + ) } } -} \ No newline at end of file +} diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index e87c8463..5399e3b3 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -1,27 +1,28 @@ /** * WebDAV Handler Module - * + * * This module implements the WebDAV protocol (RFC 4918) endpoints for OxiCloud. * It provides a complete WebDAV server implementation that allows clients to * perform file operations over HTTP, including reading, writing, and manipulating * files and directories. */ - use axum::{ Router, + body::{self, Body}, + http::{HeaderName, Request, StatusCode, header}, response::Response, - http::{StatusCode, header, HeaderName, Request}, - body::{Body, self}, }; -use uuid::Uuid; -use chrono::Utc; use bytes::Buf; +use chrono::Utc; +use uuid::Uuid; -use crate::common::di::AppState; -use crate::application::adapters::webdav_adapter::{WebDavAdapter, PropFindRequest, LockInfo, LockScope, LockType}; -use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::adapters::webdav_adapter::{ + LockInfo, LockScope, LockType, PropFindRequest, WebDavAdapter, +}; use crate::application::dtos::folder_dto::FolderDto; +use crate::common::di::AppState; use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; // Create a custom DAV header since it's not in the standard headers const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); @@ -30,10 +31,10 @@ const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token"); /** * Creates and returns the WebDAV router with all required endpoints. - * + * * This function sets up all WebDAV method handlers following RFC 4918, * mapping HTTP methods to appropriate WebDAV operations. - * + * * @return Router configured with WebDAV endpoints */ pub fn webdav_routes() -> Router { @@ -84,7 +85,7 @@ async fn handle_webdav_dispatch( path: String, ) -> Result, AppError> { let method = req.method().clone(); - + match method.as_str() { "OPTIONS" => handle_options(path).await, "GET" => handle_get(state, req, path).await, @@ -98,39 +99,42 @@ async fn handle_webdav_dispatch( "PROPPATCH" => handle_proppatch(state, req, path).await, "LOCK" => handle_lock(state, req, path).await, "UNLOCK" => handle_unlock(state, req, path).await, - _ => Err(AppError::method_not_allowed(format!("Method not allowed: {}", method))), + _ => Err(AppError::method_not_allowed(format!( + "Method not allowed: {}", + method + ))), } } /** * Handles OPTIONS requests to advertise WebDAV capabilities. - * + * * This handler responds with the DAV header indicating WebDAV compliance * level and the methods supported by this WebDAV server. - * + * * @param state The application state containing service dependencies * @param path The requested resource path * @return HTTP response with appropriate WebDAV headers */ -async fn handle_options( - _path: String, -) -> Result, AppError> { - +async fn handle_options(_path: String) -> Result, AppError> { Ok(Response::builder() .status(StatusCode::OK) .header(HEADER_DAV, "1, 2") // Class 1 and 2 WebDAV support - .header(header::ALLOW, "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK") + .header( + header::ALLOW, + "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK", + ) .body(Body::empty()) .unwrap()) } /** * Handles PROPFIND requests to retrieve resource properties. - * + * * This handler processes WebDAV PROPFIND requests according to RFC 4918, * retrieving properties of files and folders in the specified path. * It supports the Depth header to control recursion depth. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -143,32 +147,32 @@ async fn handle_propfind( path: String, ) -> Result, AppError> { // Extract depth header (cloning to avoid borrowing issues) - let depth = req.headers() + let depth = req + .headers() .get("Depth") .and_then(|v| v.to_str().ok()) .unwrap_or("infinity") .to_string(); - + let _user = { - let user_ref = req.extensions().get::().ok_or_else(|| { - AppError::unauthorized("Authentication required") - })?; + let user_ref = req + .extensions() + .get::() + .ok_or_else(|| AppError::unauthorized("Authentication required"))?; user_ref.clone() }; - + // Extract the body separately to avoid borrow issues let body_bytes = { // Convert the request into a body let body = req.into_body(); - + // Read request body body::to_bytes(body, usize::MAX) .await - .map_err(|e| { - AppError::bad_request(format!("Failed to read request body: {}", e)) - })? + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; - + // Parse PROPFIND request let propfind_request = if body_bytes.is_empty() { // Empty body means get all properties @@ -181,25 +185,27 @@ async fn handle_propfind( AppError::bad_request(format!("Failed to parse PROPFIND request: {}", e)) })? }; - + // Get folder service from state let folder_service = &state.applications.folder_service; let file_retrieval_service = &state.applications.file_retrieval_service; - + // Determine base HREF let base_href = format!("/webdav/{}/", path); - + // Check if path exists as a file or folder if path.is_empty() || path == "/" { // Root folder - let subfolders = folder_service.list_folders(None).await.map_err(|e| { - AppError::internal_error(format!("Failed to get subfolders: {}", e)) - })?; - - let files = file_retrieval_service.list_files(None).await.map_err(|e| { - AppError::internal_error(format!("Failed to get files: {}", e)) - })?; - + let subfolders = folder_service + .list_folders(None) + .await + .map_err(|e| AppError::internal_error(format!("Failed to get subfolders: {}", e)))?; + + let files = file_retrieval_service + .list_files(None) + .await + .map_err(|e| AppError::internal_error(format!("Failed to get files: {}", e)))?; + // Create root folder DTO for response let root_folder = FolderDto { id: "root".to_string(), @@ -210,7 +216,7 @@ async fn handle_propfind( modified_at: Utc::now().timestamp() as u64, is_root: true, }; - + // Generate response let mut response_body = Vec::new(); WebDavAdapter::generate_propfind_response( @@ -221,10 +227,11 @@ async fn handle_propfind( &propfind_request, &depth, &base_href, - ).map_err(|e| { + ) + .map_err(|e| { AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) })?; - + Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") @@ -233,25 +240,29 @@ async fn handle_propfind( } else { // Check if path is a folder let folder_result = folder_service.get_folder_by_path(&path).await; - + if let Ok(folder) = folder_result { // Path is a folder let files = if depth != "0" { - file_retrieval_service.list_files(Some(&folder.id)).await.map_err(|e| { - AppError::internal_error(format!("Failed to get files: {}", e)) - })? + file_retrieval_service + .list_files(Some(&folder.id)) + .await + .map_err(|e| AppError::internal_error(format!("Failed to get files: {}", e)))? } else { vec![] }; - + let subfolders = if depth != "0" { - folder_service.list_folders(Some(&folder.id)).await.map_err(|e| { - AppError::internal_error(format!("Failed to get subfolders: {}", e)) - })? + folder_service + .list_folders(Some(&folder.id)) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to get subfolders: {}", e)) + })? } else { vec![] }; - + // Generate response let mut response_body = Vec::new(); WebDavAdapter::generate_propfind_response( @@ -262,10 +273,11 @@ async fn handle_propfind( &propfind_request, &depth, &base_href, - ).map_err(|e| { + ) + .map_err(|e| { AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) })?; - + Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") @@ -274,7 +286,7 @@ async fn handle_propfind( } else { // Check if path is a file let file_result = file_retrieval_service.get_file_by_path(&path).await; - + if let Ok(file) = file_result { // Path is a file let mut response_body = Vec::new(); @@ -284,10 +296,11 @@ async fn handle_propfind( &propfind_request, &depth, &base_href, - ).map_err(|e| { + ) + .map_err(|e| { AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) })?; - + Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") @@ -303,10 +316,10 @@ async fn handle_propfind( /** * Handles PROPPATCH requests to set or remove resource properties. - * + * * This handler processes WebDAV PROPPATCH requests according to RFC 4918, * modifying properties of files and folders in the specified path. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -318,49 +331,43 @@ async fn handle_proppatch( req: Request, path: String, ) -> Result, AppError> { - let _user = req.extensions().get::().ok_or_else(|| { - AppError::unauthorized("Authentication required") - })?; - + let _user = req + .extensions() + .get::() + .ok_or_else(|| AppError::unauthorized("Authentication required"))?; + // Read request body 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)) - })?; - + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + // Parse PROPPATCH request - let (props_to_set, props_to_remove) = WebDavAdapter::parse_proppatch(body_bytes.reader()).map_err(|e| { - AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)) - })?; - + let (props_to_set, props_to_remove) = WebDavAdapter::parse_proppatch(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?; + // For now, we don't actually persist custom properties, but we respond as if we did // In a full implementation, we would store these properties in a database - + // Generate response - we'll pretend all operations succeeded let mut results = Vec::new(); - + // For each property to set, indicate success for prop in &props_to_set { results.push((&prop.name, true)); } - + // For each property to remove, indicate success for prop in &props_to_remove { results.push((prop, true)); } - + // Generate response let href = format!("/webdav/{}", path); let mut response_body = Vec::new(); - WebDavAdapter::generate_proppatch_response( - &mut response_body, - &href, - &results, - ).map_err(|e| { - AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)) - })?; - + WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( + |e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)), + )?; + Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") @@ -370,9 +377,9 @@ async fn handle_proppatch( /** * Handles GET requests to retrieve file contents. - * + * * This handler retrieves the contents of a file at the specified path. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -383,34 +390,38 @@ async fn handle_get( _req: Request, path: String, ) -> Result, AppError> { - // Get file service from state let file_retrieval_service = &state.applications.file_retrieval_service; - + // Check if path is empty (root folder) if path.is_empty() || path == "/" { return Err(AppError::bad_request("Cannot GET a directory")); } - + // Get file metadata - let file = file_retrieval_service.get_file_by_path(&path).await.map_err(|_e| { - AppError::not_found(format!("File not found: {}", path)) - })?; - + let file = file_retrieval_service + .get_file_by_path(&path) + .await + .map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?; + // Get file content - let content = file_retrieval_service.get_file_content(&file.id).await.map_err(|e| { - AppError::internal_error(format!("Failed to get file content: {}", e)) - })?; - + let content = file_retrieval_service + .get_file_content(&file.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to get file content: {}", e)))?; + // Build response Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, file.mime_type) .header(header::CONTENT_LENGTH, content.len()) .header(header::ETAG, format!("\"{}\"", file.id)) - .header(header::LAST_MODIFIED, chrono::DateTime::::from_timestamp(file.created_at as i64, 0) - .unwrap_or_else(Utc::now) - .to_rfc2822()) + .header( + header::LAST_MODIFIED, + chrono::DateTime::::from_timestamp(file.created_at as i64, 0) + .unwrap_or_else(Utc::now) + .to_rfc2822(), + ) .body(Body::from(content)) .unwrap()) } @@ -423,7 +434,6 @@ async fn handle_head( _req: Request, path: String, ) -> Result, AppError> { - let file_retrieval_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; @@ -449,31 +459,36 @@ async fn handle_head( } // Try as file - let file = file_retrieval_service.get_file_by_path(&path).await.map_err(|_e| { - AppError::not_found(format!("Resource not found: {}", path)) - })?; + let file = file_retrieval_service + .get_file_by_path(&path) + .await + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; - let content = file_retrieval_service.get_file_content(&file.id).await.map_err(|e| { - AppError::internal_error(format!("Failed to get file content: {}", e)) - })?; + let content = file_retrieval_service + .get_file_content(&file.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to get file content: {}", e)))?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &file.mime_type) .header(header::CONTENT_LENGTH, content.len()) .header(header::ETAG, format!("\"{}\"", file.id)) - .header(header::LAST_MODIFIED, chrono::DateTime::::from_timestamp(file.created_at as i64, 0) - .unwrap_or_else(Utc::now) - .to_rfc2822()) + .header( + header::LAST_MODIFIED, + chrono::DateTime::::from_timestamp(file.created_at as i64, 0) + .unwrap_or_else(Utc::now) + .to_rfc2822(), + ) .body(Body::empty()) .unwrap()) } /** * Handles PUT requests to create or update files. - * + * * This handler creates a new file or updates an existing file at the specified path. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -485,38 +500,36 @@ async fn handle_put( req: Request, path: String, ) -> Result, AppError> { - // Get file service from state let file_upload_service = &state.applications.file_upload_service; - + // Check if path is empty (root folder) if path.is_empty() || path == "/" { return Err(AppError::bad_request("Cannot PUT to root folder")); } - + // Extract content type before consuming the request - let _content_type = req.headers() + let _content_type = req + .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); - + // Read request body let body_bytes = { // Convert the request into a body let body = req.into_body(); - + // Read request body body::to_bytes(body, usize::MAX) .await - .map_err(|e| { - AppError::bad_request(format!("Failed to read request body: {}", e)) - })? + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; - + // Check if file exists let file_exists = file_upload_service.update_file(&path, &body_bytes).await; - + match file_exists { Ok(_) => { // update_file handles both update and create-if-not-found @@ -525,17 +538,18 @@ async fn handle_put( .body(Body::empty()) .unwrap()) } - Err(e) => { - Err(AppError::internal_error(format!("Failed to put file: {}", e))) - } + Err(e) => Err(AppError::internal_error(format!( + "Failed to put file: {}", + e + ))), } } /** * Handles MKCOL requests to create folders. - * + * * This handler creates a new folder at the specified path. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -546,60 +560,60 @@ async fn handle_mkcol( req: Request, path: String, ) -> Result, AppError> { - // Get folder service from state let folder_service = &state.applications.folder_service; - + // Check if path is empty (root folder) if path.is_empty() || path == "/" { return Err(AppError::conflict("Root folder already exists")); } - + // Read request body - must be empty for MKCOL let body_bytes = { // Convert the request into a body let body = req.into_body(); - + // Read request body body::to_bytes(body, usize::MAX) .await - .map_err(|e| { - AppError::bad_request(format!("Failed to read request body: {}", e)) - })? + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; - + if !body_bytes.is_empty() { - return Err(AppError::unsupported_media_type("MKCOL request body must be empty")); + return Err(AppError::unsupported_media_type( + "MKCOL request body must be empty", + )); } - + // Extract folder name from path let folder_name = path.split('/').next_back().unwrap_or("unnamed"); - + // Get parent folder path let parent_path = if let Some(idx) = path.rfind('/') { &path[..idx] } else { "" }; - + // Create folder let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { name: folder_name.to_string(), - parent_id: if parent_path.is_empty() { - None + parent_id: if parent_path.is_empty() { + None } else { // Try to get the parent folder ID from its path match folder_service.get_folder_by_path(parent_path).await { Ok(parent) => Some(parent.id), - Err(_) => None // If not found, use root + Err(_) => None, // If not found, use root } - } + }, }; - - folder_service.create_folder(create_dto).await.map_err(|e| { - AppError::internal_error(format!("Failed to create folder: {}", e)) - })?; - + + folder_service + .create_folder(create_dto) + .await + .map_err(|e| AppError::internal_error(format!("Failed to create folder: {}", e)))?; + Ok(Response::builder() .status(StatusCode::CREATED) .body(Body::empty()) @@ -608,9 +622,9 @@ async fn handle_mkcol( /** * Handles DELETE requests to remove files or folders. - * + * * This handler deletes a file or folder at the specified path. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -621,36 +635,38 @@ async fn handle_delete( _req: Request, path: String, ) -> Result, AppError> { - // Get services from state let file_retrieval_service = &state.applications.file_retrieval_service; let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; - + // Check if path is empty (root folder) if path.is_empty() || path == "/" { return Err(AppError::forbidden("Cannot delete root folder")); } - + // Check if path is a folder let folder_result = folder_service.get_folder_by_path(&path).await; - + if let Ok(folder) = folder_result { // Delete folder - folder_service.delete_folder(&folder.id).await.map_err(|e| { - AppError::internal_error(format!("Failed to delete folder: {}", e)) - })?; + folder_service + .delete_folder(&folder.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; } else { // Try to delete file - let file = file_retrieval_service.get_file_by_path(&path).await.map_err(|_e| { - AppError::not_found(format!("Resource not found: {}", path)) - })?; - - file_management_service.delete_file(&file.id).await.map_err(|e| { - AppError::internal_error(format!("Failed to delete file: {}", e)) - })?; + let file = file_retrieval_service + .get_file_by_path(&path) + .await + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; + + file_management_service + .delete_file(&file.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; } - + Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) @@ -659,9 +675,9 @@ async fn handle_delete( /** * Handles MOVE requests to rename or relocate files or folders. - * + * * This handler moves a file or folder from one path to another. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The source resource path @@ -674,20 +690,23 @@ async fn handle_move( path: String, ) -> Result, AppError> { let source_path = path; - + // Get destination from Destination header - let destination = req.headers() + let destination = req + .headers() .get("Destination") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::bad_request("Destination header required"))? .to_string(); - + // Overwrite header (RFC 4918 §9.8.4): T = overwrite, F = fail if exists - let overwrite = req.headers() + let overwrite = req + .headers() .get("Overwrite") .and_then(|v| v.to_str().ok()) - .unwrap_or("T") != "F"; - + .unwrap_or("T") + != "F"; + // Extract destination path from URL let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") { let after_prefix = &destination[webdav_prefix + 8..]; @@ -695,33 +714,44 @@ async fn handle_move( } else { return Err(AppError::bad_request("Invalid destination URL")); }; - + // Get services from state let file_retrieval_service = &state.applications.file_retrieval_service; let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; - + // Check if destination already exists (for Overwrite header compliance) if !overwrite { - let dest_exists = folder_service.get_folder_by_path(&destination_path).await.is_ok() - || file_retrieval_service.get_file_by_path(&destination_path).await.is_ok(); + let dest_exists = folder_service + .get_folder_by_path(&destination_path) + .await + .is_ok() + || file_retrieval_service + .get_file_by_path(&destination_path) + .await + .is_ok(); if dest_exists { - return Err(AppError::precondition_failed("Destination already exists and Overwrite is F")); + return Err(AppError::precondition_failed( + "Destination already exists and Overwrite is F", + )); } } - + // Check if source is a folder let folder_result = folder_service.get_folder_by_path(&source_path).await; - + if let Ok(folder) = folder_result { // Move folder - let dest_folder_name = destination_path.split('/').next_back().unwrap_or(&destination_path); + let dest_folder_name = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { &destination_path[..idx] } else { "" }; - + // Create DTOs for moving and renaming let move_dto = crate::application::dtos::folder_dto::MoveFolderDto { parent_id: if dest_parent_path.is_empty() { @@ -729,59 +759,67 @@ async fn handle_move( } else { match folder_service.get_folder_by_path(dest_parent_path).await { Ok(parent) => Some(parent.id), - Err(_) => None // If not found, use root + Err(_) => None, // If not found, use root } - } + }, }; - - folder_service.move_folder(&folder.id, move_dto).await.map_err(|e| { - AppError::internal_error(format!("Failed to move folder: {}", e)) - })?; - + + folder_service + .move_folder(&folder.id, move_dto) + .await + .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; + if folder.name != dest_folder_name { let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto { - name: dest_folder_name.to_string() + name: dest_folder_name.to_string(), }; - - folder_service.rename_folder(&folder.id, rename_dto).await.map_err(|e| { - AppError::internal_error(format!("Failed to rename folder: {}", e)) - })?; + + folder_service + .rename_folder(&folder.id, rename_dto) + .await + .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; } } else { // Try to move file - let file = file_retrieval_service.get_file_by_path(&source_path).await.map_err(|_e| { - AppError::not_found(format!("Resource not found: {}", source_path)) - })?; - - let dest_filename = destination_path.split('/').next_back().unwrap_or(&destination_path); + let file = file_retrieval_service + .get_file_by_path(&source_path) + .await + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; + + let dest_filename = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { &destination_path[..idx] } else { "" }; - + // Determine source parent path for comparison let source_parent_path = if let Some(idx) = source_path.rfind('/') { &source_path[..idx] } else { "" }; - + // Only call move_file if the parent directory actually changes if source_parent_path != dest_parent_path { - file_management_service.move_file(&file.id, Some(dest_parent_path.to_string())).await.map_err(|e| { - AppError::internal_error(format!("Failed to move file: {}", e)) - })?; + file_management_service + .move_file(&file.id, Some(dest_parent_path.to_string())) + .await + .map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?; } - + // Rename the file if the name changed if file.name != dest_filename { - file_management_service.rename_file(&file.id, dest_filename).await.map_err(|e| { - AppError::internal_error(format!("Failed to rename file: {}", e)) - })?; + file_management_service + .rename_file(&file.id, dest_filename) + .await + .map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?; } } - + Ok(Response::builder() .status(StatusCode::CREATED) .body(Body::empty()) @@ -790,9 +828,9 @@ async fn handle_move( /** * Handles COPY requests to duplicate files or folders. - * + * * This handler copies a file or folder from one path to another. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The source resource path @@ -805,20 +843,23 @@ async fn handle_copy( path: String, ) -> Result, AppError> { let source_path = path; - + // Get destination from Destination header - let destination = req.headers() + let destination = req + .headers() .get("Destination") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::bad_request("Destination header required"))? .to_string(); - + // Overwrite header (RFC 4918 §9.8.4): T = overwrite, F = fail if exists - let overwrite = req.headers() + let overwrite = req + .headers() .get("Overwrite") .and_then(|v| v.to_str().ok()) - .unwrap_or("T") != "F"; - + .unwrap_or("T") + != "F"; + // Extract destination path from URL let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") { let after_prefix = &destination[webdav_prefix + 8..]; @@ -826,101 +867,129 @@ async fn handle_copy( } else { return Err(AppError::bad_request("Invalid destination URL")); }; - + // Get depth from Depth header - let depth = req.headers() + let depth = req + .headers() .get("Depth") .and_then(|v| v.to_str().ok()) .unwrap_or("infinity"); - + // Get services from state let file_retrieval_service = &state.applications.file_retrieval_service; let file_upload_service = &state.applications.file_upload_service; let folder_service = &state.applications.folder_service; - + // Check if destination already exists (for Overwrite header compliance) if !overwrite { - let dest_exists = folder_service.get_folder_by_path(&destination_path).await.is_ok() - || file_retrieval_service.get_file_by_path(&destination_path).await.is_ok(); + let dest_exists = folder_service + .get_folder_by_path(&destination_path) + .await + .is_ok() + || file_retrieval_service + .get_file_by_path(&destination_path) + .await + .is_ok(); if dest_exists { - return Err(AppError::precondition_failed("Destination already exists and Overwrite is F")); + return Err(AppError::precondition_failed( + "Destination already exists and Overwrite is F", + )); } } - + // Check if source is a folder let folder_result = folder_service.get_folder_by_path(&source_path).await; - + if let Ok(folder) = folder_result { // Copy folder let recursive = depth != "0"; - - let dest_folder_name = destination_path.split('/').next_back().unwrap_or(&destination_path); + + let dest_folder_name = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { &destination_path[..idx] } else { "" }; - + // For now, just create a new folder and copy files individually // In a real implementation, we would have a dedicated copy_folder service method let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { name: dest_folder_name.to_string(), - parent_id: if dest_parent_path.is_empty() { - None + parent_id: if dest_parent_path.is_empty() { + None } else { // Try to get the parent folder ID from its path match folder_service.get_folder_by_path(dest_parent_path).await { Ok(parent) => Some(parent.id), - Err(_) => None // If not found, use root + Err(_) => None, // If not found, use root } - } + }, }; - - let _new_folder = folder_service.create_folder(create_dto).await.map_err(|e| { - AppError::internal_error(format!("Failed to create destination folder: {}", e)) - })?; - + + let _new_folder = folder_service + .create_folder(create_dto) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to create destination folder: {}", e)) + })?; + if recursive { // Copy subfolders and files (simplified implementation) - let files = file_retrieval_service.list_files(Some(&folder.id)).await.map_err(|e| { - AppError::internal_error(format!("Failed to list files: {}", e)) - })?; - + let files = file_retrieval_service + .list_files(Some(&folder.id)) + .await + .map_err(|e| AppError::internal_error(format!("Failed to list files: {}", e)))?; + for file in files { // Get file content if let Ok(content) = file_retrieval_service.get_file_content(&file.id).await { // Create new file in destination - file_upload_service.create_file(&destination_path, &file.name, &content, &file.mime_type).await.map_err(|e| { - AppError::internal_error(format!("Failed to copy file {}: {}", file.name, e)) - })?; + file_upload_service + .create_file(&destination_path, &file.name, &content, &file.mime_type) + .await + .map_err(|e| { + AppError::internal_error(format!( + "Failed to copy file {}: {}", + file.name, e + )) + })?; } } } } else { // Try to copy file - let file = file_retrieval_service.get_file_by_path(&source_path).await.map_err(|_e| { - AppError::not_found(format!("Resource not found: {}", source_path)) - })?; - + let file = file_retrieval_service + .get_file_by_path(&source_path) + .await + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; + // Get file content - let content = file_retrieval_service.get_file_content(&file.id).await.map_err(|e| { - AppError::internal_error(format!("Failed to get file content: {}", e)) - })?; - + let content = file_retrieval_service + .get_file_content(&file.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to get file content: {}", e)))?; + // Get destination parent path and filename - let dest_filename = destination_path.split('/').next_back().unwrap_or(&destination_path); + let dest_filename = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { &destination_path[..idx] } else { "" }; - + // Create new file in destination - file_upload_service.create_file(dest_parent_path, dest_filename, &content, &file.mime_type).await.map_err(|e| { - AppError::internal_error(format!("Failed to copy file: {}", e)) - })?; + file_upload_service + .create_file(dest_parent_path, dest_filename, &content, &file.mime_type) + .await + .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; } - + Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) @@ -929,10 +998,10 @@ async fn handle_copy( /** * Handles LOCK requests to lock resources. - * + * * This handler processes WebDAV LOCK requests according to RFC 4918, * creating a lock on a file or folder. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -945,42 +1014,44 @@ async fn handle_lock( path: String, ) -> Result, AppError> { let user = { - let user_ref = req.extensions().get::().ok_or_else(|| { - AppError::unauthorized("Authentication required") - })?; + let user_ref = req + .extensions() + .get::() + .ok_or_else(|| AppError::unauthorized("Authentication required"))?; user_ref.clone() }; - + // Get the headers that we need - let depth = req.headers() + let depth = req + .headers() .get("Depth") .and_then(|v| v.to_str().ok()) .unwrap_or("infinity") .to_string(); - - let timeout = req.headers() + + let timeout = req + .headers() .get("Timeout") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - - let if_header_value = req.headers() + + let if_header_value = req + .headers() .get("If") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - + // Extract the body separately to avoid borrow issues let body_bytes = { // Convert the request into a body let body = req.into_body(); - + // Read request body body::to_bytes(body, usize::MAX) .await - .map_err(|e| { - AppError::bad_request(format!("Failed to read request body: {}", e)) - })? + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; - + // Check if this is a lock refresh (If header with a lock token) if let Some(if_header) = if_header_value { // This is a lock refresh request @@ -990,10 +1061,10 @@ async fn handle_lock( .trim_start_matches("(<") .trim_end_matches(">)") .to_string(); - + // In a full implementation, we would look up the lock in a database // and refresh its timeout. For now, just respond as if we did. - + // Generate lock token and owner (for a real implementation, we'd store these) let lock_info = LockInfo { token, @@ -1003,18 +1074,14 @@ async fn handle_lock( scope: LockScope::Exclusive, // Default to exclusive type_: LockType::Write, // Default to write }; - + // Generate response let href = format!("/webdav/{}", path); let mut response_body = Vec::new(); - WebDavAdapter::generate_lock_response( - &mut response_body, - &lock_info, - &href, - ).map_err(|e| { - AppError::internal_error(format!("Failed to generate LOCK response: {}", e)) - })?; - + WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err( + |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), + )?; + Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") @@ -1023,10 +1090,9 @@ async fn handle_lock( .unwrap()) } else if !body_bytes.is_empty() { // Parse lock request - let (scope, type_, owner) = WebDavAdapter::parse_lockinfo(body_bytes.reader()).map_err(|e| { - AppError::bad_request(format!("Failed to parse LOCK request: {}", e)) - })?; - + let (scope, type_, owner) = WebDavAdapter::parse_lockinfo(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse LOCK request: {}", e)))?; + // Generate lock token and owner (for a real implementation, we'd store these) let token = format!("opaquelocktoken:{}", Uuid::new_v4()); let lock_info = LockInfo { @@ -1037,18 +1103,14 @@ async fn handle_lock( scope, type_, }; - + // Generate response let href = format!("/webdav/{}", path); let mut response_body = Vec::new(); - WebDavAdapter::generate_lock_response( - &mut response_body, - &lock_info, - &href, - ).map_err(|e| { - AppError::internal_error(format!("Failed to generate LOCK response: {}", e)) - })?; - + WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err( + |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), + )?; + Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") @@ -1062,10 +1124,10 @@ async fn handle_lock( /** * Handles UNLOCK requests to remove locks from resources. - * + * * This handler processes WebDAV UNLOCK requests according to RFC 4918, * removing a lock from a file or folder. - * + * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path @@ -1078,30 +1140,32 @@ async fn handle_unlock( _path: String, ) -> Result, AppError> { let _user = { - let user_ref = req.extensions().get::().ok_or_else(|| { - AppError::unauthorized("Authentication required") - })?; + let user_ref = req + .extensions() + .get::() + .ok_or_else(|| AppError::unauthorized("Authentication required"))?; user_ref.clone() }; - + // Get lock token from Lock-Token header - let lock_token = req.headers() + let lock_token = req + .headers() .get("Lock-Token") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::bad_request("Lock-Token header required"))?; - + // Extract token from header value (format: ) let _token = lock_token .trim() .trim_start_matches('<') .trim_end_matches('>') .to_string(); - + // In a full implementation, we would look up the lock in a database // and remove it. For now, just respond as if we did. - + Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) .unwrap()) -} \ No newline at end of file +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index ea8395b0..9908acce 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -2,4 +2,4 @@ pub mod handlers; pub mod routes; pub use routes::create_api_routes; -pub use routes::create_public_api_routes; \ No newline at end of file +pub use routes::create_public_api_routes; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 99c07f55..6d914958 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -1,16 +1,13 @@ -use std::sync::Arc; +use crate::common::di::AppState; use axum::{ - routing::{get, post, put, delete}, Router, extract::DefaultBodyLimit, response::Json as AxumJson, + routing::{delete, get, post, put}, }; use serde_json::json; -use tower_http::{ - compression::CompressionLayer, - trace::TraceLayer, -}; -use crate::common::di::AppState; +use std::sync::Arc; +use tower_http::{compression::CompressionLayer, trace::TraceLayer}; /// Returns the application version from Cargo.toml (compile-time constant) async fn get_version() -> AxumJson { @@ -24,15 +21,13 @@ use crate::interfaces::middleware::cache::{HttpCache, start_cache_cleanup_task}; use crate::application::services::batch_operations::BatchOperationService; -use crate::interfaces::api::handlers::folder_handler::FolderHandler; -use crate::interfaces::api::handlers::file_handler::FileHandler; -use crate::interfaces::api::handlers::i18n_handler::I18nHandler; -use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; -use crate::interfaces::api::handlers::trash_handler; use crate::interfaces::api::handlers::admin_handler; -use crate::interfaces::api::handlers::batch_handler::{ - self, BatchHandlerState -}; +use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState}; +use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; +use crate::interfaces::api::handlers::file_handler::FileHandler; +use crate::interfaces::api::handlers::folder_handler::FolderHandler; +use crate::interfaces::api::handlers::i18n_handler::I18nHandler; +use crate::interfaces::api::handlers::trash_handler; /// Creates public API routes that should NOT require authentication. /// @@ -49,12 +44,15 @@ pub fn create_public_api_routes(app_state: &AppState) -> Router { // Public share access routes — no auth required if let Some(share_service) = share_service { use crate::interfaces::api::handlers::share_handler; - + let public_share_router = Router::new() .route("/{token}", get(share_handler::access_shared_item)) - .route("/{token}/verify", post(share_handler::verify_shared_item_password)) + .route( + "/{token}/verify", + post(share_handler::verify_shared_item_password), + ) .with_state(share_service); - + router = router.nest("/s", public_share_router); } @@ -63,9 +61,12 @@ pub fn create_public_api_routes(app_state: &AppState) -> Router { let i18n_router = Router::new() .route("/locales", get(I18nHandler::get_locales)) .route("/translate", get(I18nHandler::translate)) - .route("/locales/{locale_code}", get(I18nHandler::get_translations_by_locale)) + .route( + "/locales/{locale_code}", + get(I18nHandler::get_translations_by_locale), + ) .with_state(i18n_service); - + router = router.nest("/i18n", i18n_router); } @@ -95,49 +96,57 @@ pub fn create_api_routes(app_state: &AppState) -> Router { let batch_service = Arc::new(BatchOperationService::default( file_retrieval_service.clone(), file_management_service.clone(), - folder_service.clone() + folder_service.clone(), )); - + // Create state for the batch operations handler let batch_handler_state = BatchHandlerState { batch_service: batch_service.clone(), }; - + // Implement HTTP Cache let http_cache = HttpCache::new(); - + // Define TTL values for different resource types (in seconds) - let _folders_ttl = 300; // 5 minutes - let _files_list_ttl = 300; // 5 minutes - let _i18n_ttl = 3600; // 1 hour - + let _folders_ttl = 300; // 5 minutes + let _files_list_ttl = 300; // 5 minutes + let _i18n_ttl = 3600; // 1 hour + // Start the cleanup task for HTTP cache start_cache_cleanup_task(http_cache.clone()); - + // Create the basic folders router with service operations let folders_basic_router = Router::new() .route("/", post(FolderHandler::create_folder)) .route("/", get(FolderHandler::list_root_folders)) - .route("/paginated", get(FolderHandler::list_root_folders_paginated)) + .route( + "/paginated", + get(FolderHandler::list_root_folders_paginated), + ) .route("/{id}", get(FolderHandler::get_folder)) .route("/{id}/contents", get(FolderHandler::list_folder_contents)) - .route("/{id}/contents/paginated", get(FolderHandler::list_folder_contents_paginated)) + .route( + "/{id}/contents/paginated", + get(FolderHandler::list_folder_contents_paginated), + ) .route("/{id}/rename", put(FolderHandler::rename_folder)) .route("/{id}/move", put(FolderHandler::move_folder)) .with_state(folder_service.clone()); - + // Special route for ZIP download that requires AppState instead of just FolderService let folder_zip_router = Router::new() .route("/{id}/download", get(FolderHandler::download_folder_zip)) .with_state(app_state.clone()); - + // Create folder operations that use trash (requires full AppState) - let folders_ops_router = Router::new() - .route("/{id}", delete(FolderHandler::delete_folder_with_trash)); - + let folders_ops_router = + Router::new().route("/{id}", delete(FolderHandler::delete_folder_with_trash)); + // Merge the routers - let folders_router = folders_basic_router.merge(folders_ops_router).merge(folder_zip_router); - + let folders_router = folders_basic_router + .merge(folders_ops_router) + .merge(folder_zip_router); + // Create file routes for basic operations and trash-enabled delete let basic_file_router = Router::new() .route("/", get(FileHandler::list_files_query)) @@ -146,16 +155,16 @@ pub fn create_api_routes(app_state: &AppState) -> Router { .route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10 GB for file uploads .with_state(app_state.clone()); - + // File operations with trash support let file_operations_router = Router::new() .route("/{id}", delete(FileHandler::delete_file)) .route("/{id}/move", put(FileHandler::move_file_simple)) .route("/{id}/rename", put(FileHandler::rename_file)); - + // Merge the routers let files_router = basic_file_router.merge(file_operations_router); - + // Create routes for batch operations let batch_router = Router::new() // File operations @@ -168,11 +177,11 @@ pub fn create_api_routes(app_state: &AppState) -> Router { .route("/folders/create", post(batch_handler::create_folders_batch)) .route("/folders/get", post(batch_handler::get_folders_batch)) .with_state(batch_handler_state); - + // Create search routes if the service is available let search_router = if search_service.is_some() { use crate::interfaces::api::handlers::search_handler::SearchHandler; - + Router::new() // Simple search with query parameters .route("/", get(SearchHandler::search_files_get)) @@ -184,13 +193,13 @@ pub fn create_api_routes(app_state: &AppState) -> Router { } else { Router::new() }; - + // Direct handler implementations for sharing, without depending on ShareHandler - + // Create routes for shared resources management (requires auth) let share_router = if let Some(share_service) = share_service.clone() { use crate::interfaces::api::handlers::share_handler; - + Router::new() .route("/", post(share_handler::create_shared_link)) .route("/", get(share_handler::get_user_shares)) @@ -206,47 +215,86 @@ pub fn create_api_routes(app_state: &AppState) -> Router { // Create routes for favorites if the service is available let favorites_router = if let Some(favorites_service) = favorites_service.clone() { use crate::interfaces::api::handlers::favorites_handler; - + Router::new() .route("/", get(favorites_handler::get_favorites)) - .route("/{item_type}/{item_id}", post(favorites_handler::add_favorite)) - .route("/{item_type}/{item_id}", delete(favorites_handler::remove_favorite)) + .route( + "/{item_type}/{item_id}", + post(favorites_handler::add_favorite), + ) + .route( + "/{item_type}/{item_id}", + delete(favorites_handler::remove_favorite), + ) .with_state(favorites_service.clone()) } else { Router::new() }; - + // Create routes for recent items if the service is available let recent_router = if let Some(recent_service) = recent_service.clone() { use crate::interfaces::api::handlers::recent_handler; - + Router::new() .route("/", get(recent_handler::get_recent_items)) - .route("/{item_type}/{item_id}", post(recent_handler::record_item_access)) - .route("/{item_type}/{item_id}", delete(recent_handler::remove_from_recent)) + .route( + "/{item_type}/{item_id}", + post(recent_handler::record_item_access), + ) + .route( + "/{item_type}/{item_id}", + delete(recent_handler::remove_from_recent), + ) .route("/clear", delete(recent_handler::clear_recent_items)) .with_state(recent_service.clone()) } else { Router::new() }; - + // Create routes for chunked uploads (large files >10MB) let chunked_upload_router = Router::new() .route("/", post(ChunkedUploadHandler::create_upload)) - .route("/{upload_id}", axum::routing::patch(ChunkedUploadHandler::upload_chunk)) - .route("/{upload_id}", axum::routing::head(ChunkedUploadHandler::get_upload_status)) - .route("/{upload_id}/complete", post(ChunkedUploadHandler::complete_upload)) + .route( + "/{upload_id}", + axum::routing::patch(ChunkedUploadHandler::upload_chunk), + ) + .route( + "/{upload_id}", + axum::routing::head(ChunkedUploadHandler::get_upload_status), + ) + .route( + "/{upload_id}/complete", + post(ChunkedUploadHandler::complete_upload), + ) .route("/{upload_id}", delete(ChunkedUploadHandler::cancel_upload)) .with_state(Arc::new(app_state.clone())); // Create routes for deduplication endpoints let dedup_router = Router::new() - .route("/check/{hash}", get(super::handlers::dedup_handler::DedupHandler::check_hash)) - .route("/upload", post(super::handlers::dedup_handler::DedupHandler::upload_with_dedup)) - .route("/stats", get(super::handlers::dedup_handler::DedupHandler::get_stats)) - .route("/blob/{hash}", get(super::handlers::dedup_handler::DedupHandler::get_blob)) - .route("/blob/{hash}", delete(super::handlers::dedup_handler::DedupHandler::remove_reference)) - .route("/recalculate", post(super::handlers::dedup_handler::DedupHandler::recalculate_stats)) + .route( + "/check/{hash}", + get(super::handlers::dedup_handler::DedupHandler::check_hash), + ) + .route( + "/upload", + post(super::handlers::dedup_handler::DedupHandler::upload_with_dedup), + ) + .route( + "/stats", + get(super::handlers::dedup_handler::DedupHandler::get_stats), + ) + .route( + "/blob/{hash}", + get(super::handlers::dedup_handler::DedupHandler::get_blob), + ) + .route( + "/blob/{hash}", + delete(super::handlers::dedup_handler::DedupHandler::remove_reference), + ) + .route( + "/recalculate", + post(super::handlers::dedup_handler::DedupHandler::recalculate_stats), + ) .with_state(app_state.clone()); let mut router = Router::new() @@ -258,13 +306,12 @@ pub fn create_api_routes(app_state: &AppState) -> Router { .nest("/search", search_router) .nest("/shares", share_router) .nest("/favorites", favorites_router) - .nest("/recent", recent_router) - ; - + .nest("/recent", recent_router); + // Re-enable trash routes to make the trash view work if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view"); - + let trash_router = Router::new() .route("/", get(trash_handler::get_trash_items)) .route("/files/{id}", delete(trash_handler::move_file_to_trash)) @@ -273,24 +320,23 @@ pub fn create_api_routes(app_state: &AppState) -> Router { .route("/{id}", delete(trash_handler::delete_permanently)) .route("/empty", delete(trash_handler::empty_trash)) .with_state(app_state.clone()); - + router = router.nest("/trash", trash_router); } else { tracing::warn!("Trash service not available - trash view will not work"); } - + // NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs // for client compatibility, NOT under /api. - + // NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav) // in main.rs for protocol compliance, NOT under /api. // Admin settings routes (protected by admin_guard inside the handler) - let admin_router = admin_handler::admin_routes() - .with_state(app_state.clone()); + let admin_router = admin_handler::admin_routes().with_state(app_state.clone()); router = router.nest("/admin", admin_router); router .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) -} \ No newline at end of file +} diff --git a/src/interfaces/errors.rs b/src/interfaces/errors.rs index ea00c532..666a353e 100644 --- a/src/interfaces/errors.rs +++ b/src/interfaces/errors.rs @@ -1,122 +1,134 @@ -//! HTTP/API Error types for the interfaces layer. -//! -//! This module contains error types specific to the HTTP/API layer. -//! These errors handle the conversion from domain errors to HTTP responses. - -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use serde::Serialize; - -use crate::domain::errors::{DomainError, ErrorKind}; - -/// Error type for HTTP/API responses. -/// -/// This struct represents errors that will be returned to HTTP clients. -/// It contains the HTTP status code, a user-friendly message, and an error type identifier. -#[derive(Debug)] -pub struct AppError { - pub status_code: StatusCode, - pub message: String, - pub error_type: String, -} - -/// JSON response structure for errors. -#[derive(Serialize)] -pub struct ErrorResponse { - pub status: String, - pub message: String, - pub error_type: String, -} - -impl AppError { - /// Create a new AppError with custom status code, message and error type. - pub fn new(status_code: StatusCode, message: impl Into, error_type: impl Into) -> Self { - Self { - status_code, - message: message.into(), - error_type: error_type.into(), - } - } - - /// Create a 400 Bad Request error. - pub fn bad_request(message: impl Into) -> Self { - Self::new(StatusCode::BAD_REQUEST, message, "BadRequest") - } - - /// Create a 401 Unauthorized error. - pub fn unauthorized(message: impl Into) -> Self { - Self::new(StatusCode::UNAUTHORIZED, message, "Unauthorized") - } - - /// Create a 403 Forbidden error. - pub fn forbidden(message: impl Into) -> Self { - Self::new(StatusCode::FORBIDDEN, message, "Forbidden") - } - - /// Create a 404 Not Found error. - pub fn not_found(message: impl Into) -> Self { - Self::new(StatusCode::NOT_FOUND, message, "NotFound") - } - - /// Create a 500 Internal Server Error. - pub fn internal_error(message: impl Into) -> Self { - Self::new(StatusCode::INTERNAL_SERVER_ERROR, message, "InternalError") - } - - /// Create a 405 Method Not Allowed error. - pub fn method_not_allowed(message: impl Into) -> Self { - Self::new(StatusCode::METHOD_NOT_ALLOWED, message, "MethodNotAllowed") - } - - /// Create a 409 Conflict error. - pub fn conflict(message: impl Into) -> Self { - Self::new(StatusCode::CONFLICT, message, "Conflict") - } - - /// Create a 415 Unsupported Media Type error. - pub fn unsupported_media_type(message: impl Into) -> Self { - Self::new(StatusCode::UNSUPPORTED_MEDIA_TYPE, message, "UnsupportedMediaType") - } - - /// Create a 412 Precondition Failed error. - pub fn precondition_failed(message: impl Into) -> Self { - Self::new(StatusCode::PRECONDITION_FAILED, message, "PreconditionFailed") - } -} - -impl From for AppError { - fn from(err: DomainError) -> Self { - let status_code = match err.kind { - ErrorKind::NotFound => StatusCode::NOT_FOUND, - ErrorKind::AlreadyExists => StatusCode::CONFLICT, - ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, - ErrorKind::AccessDenied => StatusCode::FORBIDDEN, - ErrorKind::Timeout => StatusCode::REQUEST_TIMEOUT, - ErrorKind::InternalError => StatusCode::INTERNAL_SERVER_ERROR, - ErrorKind::NotImplemented => StatusCode::NOT_IMPLEMENTED, - ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED, - ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, - }; - - Self { - status_code, - message: err.message, - error_type: err.kind.to_string(), - } - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let status = self.status_code; - let error_response = ErrorResponse { - status: status.to_string(), - message: self.message, - error_type: self.error_type, - }; - - let body = Json(error_response); - (status, body).into_response() - } -} +//! HTTP/API Error types for the interfaces layer. +//! +//! This module contains error types specific to the HTTP/API layer. +//! These errors handle the conversion from domain errors to HTTP responses. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +use crate::domain::errors::{DomainError, ErrorKind}; + +/// Error type for HTTP/API responses. +/// +/// This struct represents errors that will be returned to HTTP clients. +/// It contains the HTTP status code, a user-friendly message, and an error type identifier. +#[derive(Debug)] +pub struct AppError { + pub status_code: StatusCode, + pub message: String, + pub error_type: String, +} + +/// JSON response structure for errors. +#[derive(Serialize)] +pub struct ErrorResponse { + pub status: String, + pub message: String, + pub error_type: String, +} + +impl AppError { + /// Create a new AppError with custom status code, message and error type. + pub fn new( + status_code: StatusCode, + message: impl Into, + error_type: impl Into, + ) -> Self { + Self { + status_code, + message: message.into(), + error_type: error_type.into(), + } + } + + /// Create a 400 Bad Request error. + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message, "BadRequest") + } + + /// Create a 401 Unauthorized error. + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, message, "Unauthorized") + } + + /// Create a 403 Forbidden error. + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message, "Forbidden") + } + + /// Create a 404 Not Found error. + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message, "NotFound") + } + + /// Create a 500 Internal Server Error. + pub fn internal_error(message: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message, "InternalError") + } + + /// Create a 405 Method Not Allowed error. + pub fn method_not_allowed(message: impl Into) -> Self { + Self::new(StatusCode::METHOD_NOT_ALLOWED, message, "MethodNotAllowed") + } + + /// Create a 409 Conflict error. + pub fn conflict(message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, message, "Conflict") + } + + /// Create a 415 Unsupported Media Type error. + pub fn unsupported_media_type(message: impl Into) -> Self { + Self::new( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + message, + "UnsupportedMediaType", + ) + } + + /// Create a 412 Precondition Failed error. + pub fn precondition_failed(message: impl Into) -> Self { + Self::new( + StatusCode::PRECONDITION_FAILED, + message, + "PreconditionFailed", + ) + } +} + +impl From for AppError { + fn from(err: DomainError) -> Self { + let status_code = match err.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::AlreadyExists => StatusCode::CONFLICT, + ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, + ErrorKind::AccessDenied => StatusCode::FORBIDDEN, + ErrorKind::Timeout => StatusCode::REQUEST_TIMEOUT, + ErrorKind::InternalError => StatusCode::INTERNAL_SERVER_ERROR, + ErrorKind::NotImplemented => StatusCode::NOT_IMPLEMENTED, + ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED, + ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, + }; + + Self { + status_code, + message: err.message, + error_type: err.kind.to_string(), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status_code; + let error_response = ErrorResponse { + status: status.to_string(), + message: self.message, + error_type: self.error_type, + }; + + let body = Json(error_response); + (status, body).into_response() + } +} diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 0b51bf53..7f53a510 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -1,11 +1,11 @@ -use std::sync::Arc; -use std::convert::Infallible; use axum::{ - extract::{State, Request, FromRequestParts}, - http::{StatusCode, HeaderMap, header, request::Parts}, + extract::{FromRequestParts, Request, State}, + http::{HeaderMap, StatusCode, header, request::Parts}, middleware::Next, - response::{Response, IntoResponse}, + response::{IntoResponse, Response}, }; +use std::convert::Infallible; +use std::sync::Arc; use crate::common::di::AppState; @@ -77,7 +77,10 @@ where async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { Ok(OptionalUserId( - parts.extensions.get::().map(|cu| cu.id.clone()), + parts + .extensions + .get::() + .map(|cu| cu.id.clone()), )) } } @@ -94,12 +97,12 @@ where type Rejection = Infallible; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - Ok(OptionalAuthUser( - parts.extensions.get::().map(|cu| AuthUser { + Ok(OptionalAuthUser(parts.extensions.get::().map( + |cu| AuthUser { id: cu.id.clone(), username: cu.username.clone(), - }), - )) + }, + ))) } } @@ -108,19 +111,19 @@ where pub enum AuthError { #[error("Token not provided")] TokenNotProvided, - + #[error("Invalid token: {0}")] InvalidToken(String), - + #[error("Token expired")] TokenExpired, - + #[error("User not found")] UserNotFound, - + #[error("Access denied: {0}")] AccessDenied(String), - + #[error("Authentication service unavailable")] AuthServiceUnavailable, } @@ -128,12 +131,17 @@ pub enum AuthError { impl IntoResponse for AuthError { fn into_response(self) -> Response { let (status, error_message) = match self { - AuthError::TokenNotProvided => (StatusCode::UNAUTHORIZED, "Token not provided".to_string()), + AuthError::TokenNotProvided => { + (StatusCode::UNAUTHORIZED, "Token not provided".to_string()) + } AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg), AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()), AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()), AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg), - AuthError::AuthServiceUnavailable => (StatusCode::INTERNAL_SERVER_ERROR, "Authentication service unavailable".to_string()), + AuthError::AuthServiceUnavailable => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Authentication service unavailable".to_string(), + ), }; let body = axum::Json(serde_json::json!({ @@ -160,15 +168,15 @@ pub async fn auth_middleware( .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) .ok_or(AuthError::TokenNotProvided)?; - + // Validate that the token is not empty let token_str = token_str.trim(); if token_str.is_empty() { return Err(AuthError::TokenNotProvided); } - + tracing::debug!("Processing authentication token"); - + // Validate the token using the authentication service if let Some(auth_service) = state.auth_service.as_ref() { let token_service = &auth_service.token_service; @@ -183,14 +191,14 @@ pub async fn auth_middleware( }; request.extensions_mut().insert(current_user); return Ok(next.run(request).await); - }, + } Err(e) => { tracing::warn!("Token validation failed: {}", e); return Err(AuthError::InvalidToken(format!("Invalid token: {}", e))); } } } - + // If no authentication service is available, deny access tracing::error!("Auth middleware invoked but auth service is not configured"); Err(AuthError::AuthServiceUnavailable) @@ -200,22 +208,23 @@ pub async fn auth_middleware( /// /// Must be applied AFTER auth_middleware, as it depends on /// `CurrentUser` being present in the request extensions. -pub async fn require_admin( - request: Request, - next: Next, -) -> Response { +pub async fn require_admin(request: Request, next: Next) -> Response { // Get the CurrentUser inserted by auth_middleware if let Some(current_user) = request.extensions().get::() { if current_user.role == "admin" { tracing::debug!("Admin access granted for user: {}", current_user.username); return next.run(request).await; } - tracing::warn!("Admin access denied for user: {} (role: {})", current_user.username, current_user.role); + tracing::warn!( + "Admin access denied for user: {} (role: {})", + current_user.username, + current_user.role + ); } else { tracing::warn!("Admin check failed: no authenticated user in request"); } - + // Access denied let error = AuthError::AccessDenied("Admin role required".to_string()); error.into_response() -} \ No newline at end of file +} diff --git a/src/interfaces/middleware/cache.rs b/src/interfaces/middleware/cache.rs index 50c63504..1885f7b3 100644 --- a/src/interfaces/middleware/cache.rs +++ b/src/interfaces/middleware/cache.rs @@ -3,22 +3,22 @@ use axum::{ http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode}, middleware::Next, }; -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; -use std::time::{Duration, SystemTime}; +use bytes::Bytes; use chrono::{DateTime, Utc}; use serde::Serialize; -use std::sync::{Arc, Mutex}; use std::collections::HashMap; -use tower::{Layer, Service}; -use std::task::{Context, Poll}; -use std::pin::Pin; +use std::collections::hash_map::DefaultHasher; use std::future::Future; -use bytes::Bytes; +use std::hash::{Hash, Hasher}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime}; +use tower::{Layer, Service}; use tracing::{debug, info}; -const MAX_CACHE_ENTRIES: usize = 1000; // Maximum number of cache entries -const DEFAULT_MAX_AGE: u64 = 60; // Default time-to-live in seconds +const MAX_CACHE_ENTRIES: usize = 1000; // Maximum number of cache entries +const DEFAULT_MAX_AGE: u64 = 60; // Default time-to-live in seconds // Type definitions for clarity type CacheKey = String; @@ -56,7 +56,7 @@ impl HttpCache { default_max_age: DEFAULT_MAX_AGE, } } - + /// Creates a new instance with a specified time-to-live pub fn with_max_age(max_age: u64) -> Self { Self { @@ -64,65 +64,74 @@ impl HttpCache { default_max_age: max_age, } } - + /// Gets cache statistics pub fn stats(&self) -> (usize, usize) { let lock = self.cache.lock().unwrap(); let total = lock.len(); - + // Count valid entries let _now = SystemTime::now(); - let valid = lock.values().filter(|entry| { - match entry.timestamp.elapsed() { + let valid = lock + .values() + .filter(|entry| match entry.timestamp.elapsed() { Ok(elapsed) => elapsed.as_secs() < entry.max_age, Err(_) => false, - } - }).count(); - + }) + .count(); + (total, valid) } - + /// Cleans up expired entries pub fn cleanup(&self) -> usize { let mut lock = self.cache.lock().unwrap(); let initial_count = lock.len(); - + // Remove expired entries let _now = SystemTime::now(); - lock.retain(|_, entry| { - match entry.timestamp.elapsed() { - Ok(elapsed) => elapsed.as_secs() < entry.max_age, - Err(_) => false, - } + lock.retain(|_, entry| match entry.timestamp.elapsed() { + Ok(elapsed) => elapsed.as_secs() < entry.max_age, + Err(_) => false, }); - + let removed = initial_count - lock.len(); debug!("HttpCache cleanup: removed {} expired entries", removed); - + removed } - + /// Sets an entry in the cache - fn set(&self, key: &str, etag: EntityTag, data: Option, headers: HeaderMap, max_age: Option) { + fn set( + &self, + key: &str, + etag: EntityTag, + data: Option, + headers: HeaderMap, + max_age: Option, + ) { let mut lock = self.cache.lock().unwrap(); - + // Apply eviction policy if the cache is full if lock.len() >= MAX_CACHE_ENTRIES { debug!("Cache full, removing oldest entries"); // Remove the oldest 10% of entries self.evict_oldest(&mut lock, MAX_CACHE_ENTRIES / 10); } - + // Store the new entry - lock.insert(key.to_string(), CacheEntry { - etag, - data, - headers, - timestamp: SystemTime::now(), - max_age: max_age.unwrap_or(self.default_max_age), - }); + lock.insert( + key.to_string(), + CacheEntry { + etag, + data, + headers, + timestamp: SystemTime::now(), + max_age: max_age.unwrap_or(self.default_max_age), + }, + ); } - + /// Removes the oldest entries from the cache fn evict_oldest(&self, cache: &mut HashMap, count: usize) { // Sort by timestamp @@ -130,20 +139,20 @@ impl HttpCache { .iter() .map(|(key, entry)| (key.clone(), entry.timestamp)) .collect(); - + // Sort by timestamp (oldest first) entries.sort_by(|a, b| a.1.cmp(&b.1)); - + // Remove the oldest entries for (key, _) in entries.iter().take(count) { cache.remove(key); } } - + /// Gets an entry from the cache fn get(&self, key: &str) -> Option { let lock = self.cache.lock().unwrap(); - + // Look up the entry if let Some(entry) = lock.get(key) { // Check if it has expired @@ -158,17 +167,17 @@ impl HttpCache { } } } - + None } - + /// Generates a simple ETag for a block of bytes fn calculate_etag_for_bytes(&self, bytes: &[u8]) -> EntityTag { // Calculate hash let mut hasher = DefaultHasher::new(); bytes.hash(&mut hasher); let hash = hasher.finish(); - + format!("\"{}\"", hash) } } @@ -180,80 +189,92 @@ pub async fn cache_middleware( max_age: Option, req: Request, next: Next, -) -> Result, (StatusCode, String)> -where - T: Serialize +) -> Result, (StatusCode, String)> +where + T: Serialize, { // Only apply cache for GET requests if req.method() != Method::GET { return Ok(next.run(req).await); } - + // Check if the response is cached - let if_none_match = req.headers() + let if_none_match = req + .headers() .get("if-none-match") .and_then(|v| v.to_str().ok()); - + // If there is a cache entry if let Some(cache_entry) = cache.get(cache_key) { // Check if the client already has the updated version if let Some(client_etag) = if_none_match - && client_etag == cache_entry.etag { - // The client has the most recent version, send 304 Not Modified - debug!("Cache hit (304) for key: {}", cache_key); - return Ok(create_not_modified_response(&cache_entry)); - } - + && client_etag == cache_entry.etag + { + // The client has the most recent version, send 304 Not Modified + debug!("Cache hit (304) for key: {}", cache_key); + return Ok(create_not_modified_response(&cache_entry)); + } + // The client needs the updated version if let Some(data) = &cache_entry.data { debug!("Cache hit (200) for key: {}", cache_key); - + // Create response with cached data let mut response = Response::new(Body::from(data.clone())); - + // Copy original headers for (key, value) in &cache_entry.headers { if !key.as_str().eq_ignore_ascii_case("transfer-encoding") { response.headers_mut().insert(key.clone(), value.clone()); } } - + // Add cache headers - set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age)); - + set_cache_headers( + &mut response, + &cache_entry.etag, + max_age.unwrap_or(cache_entry.max_age), + ); + return Ok(response); } } - + // Not cached or expired, continue with the middleware debug!("Cache miss for key: {}", cache_key); let response = next.run(req).await; - + // Don't cache errors if !response.status().is_success() { return Ok(response); } - + // Convert the response to calculate the ETag let (parts, _body) = response.into_parts(); - let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10).await.unwrap_or_default(); - + let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10) + .await + .unwrap_or_default(); + // Calculate ETag let etag = cache.calculate_etag_for_bytes(&bytes); - + // Save to cache cache.set( - cache_key, - etag.clone(), + cache_key, + etag.clone(), Some(bytes.clone()), parts.headers.clone(), - max_age + max_age, ); - + // Create the response with ETag let mut response = Response::from_parts(parts, Body::from(bytes)); - set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache.default_max_age)); - + set_cache_headers( + &mut response, + &etag, + max_age.unwrap_or(cache.default_max_age), + ); + Ok(response) } @@ -263,18 +284,20 @@ fn create_not_modified_response(entry: &CacheEntry) -> Response { .status(StatusCode::NOT_MODIFIED) .body(Body::empty()) .unwrap(); - + // Copy cache headers if let Some(cache_control) = entry.headers.get("cache-control") { - response.headers_mut().insert("cache-control", cache_control.clone()); + response + .headers_mut() + .insert("cache-control", cache_control.clone()); } - + // Add ETag response.headers_mut().insert( - "etag", - HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static("")) + "etag", + HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static("")), ); - + response } @@ -282,23 +305,23 @@ fn create_not_modified_response(entry: &CacheEntry) -> Response { fn set_cache_headers(response: &mut Response, etag: &str, max_age: u64) { // Add ETag response.headers_mut().insert( - "etag", - HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static("")) + "etag", + HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static("")), ); - + // Configure Cache-Control let cache_control = format!("public, max-age={}", max_age); response.headers_mut().insert( "cache-control", - HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static("")) + HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static("")), ); - + // Add Last-Modified header let now: DateTime = Utc::now(); let last_modified = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); response.headers_mut().insert( "last-modified", - HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static("")) + HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static("")), ); } @@ -317,7 +340,7 @@ impl HttpCacheLayer { max_age: None, } } - + /// Sets the maximum time-to-live pub fn with_max_age(mut self, max_age: u64) -> Self { self.max_age = Some(max_age); @@ -327,7 +350,7 @@ impl HttpCacheLayer { impl Layer for HttpCacheLayer { type Service = HttpCacheService; - + fn layer(&self, service: S) -> Self::Service { HttpCacheService { inner: service, @@ -358,15 +381,15 @@ where type Response = Response; type Error = Box; type Future = Pin> + Send>>; - + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.inner.poll_ready(cx).map_err(|e| e.into()) } - + fn call(&mut self, req: Request) -> Self::Future { // Generate cache key let cache_key = req.uri().path().to_string(); - + // Only apply cache for GET requests if req.method() != Method::GET { let future = self.inner.call(req); @@ -375,41 +398,46 @@ where Ok(response_map_body(response).await) }); } - + // Get client ETag - let if_none_match = req.headers() + let if_none_match = req + .headers() .get("if-none-match") .and_then(|v| v.to_str().ok()); - + // Check if there is a cache entry let cache_clone = self.cache.clone(); let max_age = self.max_age; let entry = cache_clone.get(&cache_key); - + match entry { Some(cache_entry) if if_none_match == Some(&cache_entry.etag) => { // The client has the correct version, send 304 debug!("Cache HIT (304): {}", cache_key); let response = create_not_modified_response(&cache_entry); Box::pin(async move { Ok(response) }) - }, + } Some(cache_entry) if cache_entry.data.is_some() => { // The client needs the updated version debug!("Cache HIT (200): {}", cache_key); let mut response = Response::new(Body::from(cache_entry.data.clone().unwrap())); - + // Copy original headers for (key, value) in &cache_entry.headers { if !key.as_str().eq_ignore_ascii_case("transfer-encoding") { response.headers_mut().insert(key.clone(), value.clone()); } } - + // Add cache headers - set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age)); - + set_cache_headers( + &mut response, + &cache_entry.etag, + max_age.unwrap_or(cache_entry.max_age), + ); + Box::pin(async move { Ok(response) }) - }, + } _ => { // Not cached or expired debug!("Cache MISS: {}", cache_key); @@ -417,36 +445,40 @@ where let cache_clone = self.cache.clone(); let max_age = self.max_age; let cache_key = cache_key.clone(); - + Box::pin(async move { let response = future.await.map_err(|e| e.into())?; let response = response_map_body(response).await; - + // Don't cache errors if !response.status().is_success() { return Ok(response); } - + // Get the body and calculate ETag let (parts, body) = response.into_parts(); let bytes = axum::body::to_bytes(body, 1024 * 1024 * 10).await?; - + // Calculate ETag let etag = cache_clone.calculate_etag_for_bytes(&bytes); - + // Save to cache cache_clone.set( - &cache_key, - etag.clone(), + &cache_key, + etag.clone(), Some(bytes.clone()), parts.headers.clone(), - max_age + max_age, ); - + // Create the response with ETag let mut response = Response::from_parts(parts, Body::from(bytes)); - set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache_clone.default_max_age)); - + set_cache_headers( + &mut response, + &etag, + max_age.unwrap_or(cache_clone.default_max_age), + ); + Ok(response) }) } @@ -481,13 +513,16 @@ where pub fn start_cache_cleanup_task(cache: HttpCache) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(300)); // Every 5 minutes - + loop { interval.tick().await; let removed = cache.cleanup(); let (total, valid) = cache.stats(); - - info!("HTTP Cache cleanup: removed {}, current: {}/{}", removed, valid, total); + + info!( + "HTTP Cache cleanup: removed {}, current: {}/{}", + removed, valid, total + ); } }); } @@ -496,49 +531,61 @@ pub fn start_cache_cleanup_task(cache: HttpCache) { mod tests { use super::*; use serde::{Deserialize, Serialize}; - + #[derive(Debug, Serialize, Deserialize, Hash)] struct TestData { id: u32, name: String, } - + #[tokio::test] async fn test_etag_generation() { let cache = HttpCache::new(); - - let data1 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap(); - let data2 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap(); - let data3 = serde_json::to_vec(&TestData { id: 2, name: "Test".to_string() }).unwrap(); - + + let data1 = serde_json::to_vec(&TestData { + id: 1, + name: "Test".to_string(), + }) + .unwrap(); + let data2 = serde_json::to_vec(&TestData { + id: 1, + name: "Test".to_string(), + }) + .unwrap(); + let data3 = serde_json::to_vec(&TestData { + id: 2, + name: "Test".to_string(), + }) + .unwrap(); + let etag1 = cache.calculate_etag_for_bytes(&data1); let etag2 = cache.calculate_etag_for_bytes(&data2); let etag3 = cache.calculate_etag_for_bytes(&data3); - + // Same data should generate the same ETag assert_eq!(etag1, etag2); - + // Different data should generate different ETags assert_ne!(etag1, etag3); } - + #[tokio::test] async fn test_cache_hit_miss() { let cache = HttpCache::new(); - + // Create test data directly as Bytes let bytes1 = Bytes::from(r#"{"id":1,"name":"Test"}"#); let headers1 = HeaderMap::new(); - + let etag1 = cache.calculate_etag_for_bytes(&bytes1); cache.set("test", etag1.clone(), Some(bytes1.clone()), headers1, None); - + // Verify cache hit let entry = cache.get("test").unwrap(); assert_eq!(entry.etag, etag1); assert_eq!(entry.data.unwrap(), bytes1); - + // Verify cache miss assert!(cache.get("nonexistent").is_none()); } -} \ No newline at end of file +} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index fb22eeea..58873880 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1,3 +1,3 @@ -pub mod cache; pub mod auth; -pub mod redirect; // Add redirect middleware for API to Axum transition \ No newline at end of file +pub mod cache; +pub mod redirect; // Add redirect middleware for API to Axum transition diff --git a/src/interfaces/middleware/redirect.rs b/src/interfaces/middleware/redirect.rs index 5c8e3c09..b234a0f5 100644 --- a/src/interfaces/middleware/redirect.rs +++ b/src/interfaces/middleware/redirect.rs @@ -1,12 +1,8 @@ -use std::task::{Context, Poll}; +use axum::http::{Uri, uri::PathAndQuery}; +use axum::{extract::Request, middleware::Next, response::Response}; use std::future::Future; use std::pin::Pin; -use axum::{ - extract::Request, - response::Response, - middleware::Next, -}; -use axum::http::{uri::PathAndQuery, Uri}; +use std::task::{Context, Poll}; use tower::{Layer, Service}; /// A middleware that redirects specific paths to the proper Axum routes. @@ -15,7 +11,7 @@ pub struct RedirectMiddleware { inner: S, } -impl Service for RedirectMiddleware +impl Service for RedirectMiddleware where S: Service + Send + 'static, S::Future: Send + 'static, @@ -33,7 +29,7 @@ where // Log the incoming request let uri = request.uri().clone(); let path = uri.path().to_string(); - + // Check and potentially redirect file-related API routes if path.starts_with("/api/files") { // Handle file-related redirects @@ -44,23 +40,28 @@ where // File download request - let's adjust the URI to match the Axum route // Extract the ID from the path let file_id = &path[11..]; - tracing::info!("Redirecting file download request: {} to /api/files/{}", path, file_id); - + tracing::info!( + "Redirecting file download request: {} to /api/files/{}", + path, + file_id + ); + // Create a new URI for the Axum route let uri_clone = uri.clone(); let mut parts = uri_clone.into_parts(); - let query = parts.path_and_query + let query = parts + .path_and_query .as_ref() .and_then(|pq| pq.query()) .map(|q| format!("?{}", q)) .unwrap_or_default(); - + let new_path = format!("/api/files/{}{}", file_id, query); parts.path_and_query = Some( PathAndQuery::from_maybe_shared(new_path.into_bytes()) - .expect("Failed to create path and query") + .expect("Failed to create path and query"), ); - + let new_uri = Uri::from_parts(parts).expect("Failed to create URI"); *request.uri_mut() = new_uri; } @@ -69,10 +70,10 @@ where tracing::debug!("Folder request detected: {}", path); // We might need to add specific redirects for folder operations here } - + // Pass the request to the inner service let future = self.inner.call(request); - + Box::pin(async move { let response = future.await?; Ok(response) @@ -93,27 +94,27 @@ impl Layer for RedirectLayer { } /// Axum middleware function that can be applied directly to routes -pub async fn redirect_middleware( - request: Request, - next: Next, -) -> Response { +pub async fn redirect_middleware(request: Request, next: Next) -> Response { // Get the path let path = request.uri().path().to_string(); - + // Process the request based on the path - if path.starts_with("/api/files") || path.starts_with("/api/folders") || path.starts_with("/api/auth") { + if path.starts_with("/api/files") + || path.starts_with("/api/folders") + || path.starts_with("/api/auth") + { tracing::debug!("API request detected in middleware: {}", path); // Log additional information about the request if let Some(content_type) = request.headers().get("content-type") { tracing::debug!("Content-Type: {:?}", content_type); } - + // For debugging auth-related requests if path.starts_with("/api/auth") { tracing::info!("Auth API request: {} method: {}", path, request.method()); } } - + // Continue the middleware chain next.run(request).await -} \ No newline at end of file +} diff --git a/src/interfaces/mod.rs b/src/interfaces/mod.rs index 83e6e6d0..742d9154 100644 --- a/src/interfaces/mod.rs +++ b/src/interfaces/mod.rs @@ -1,7 +1,7 @@ pub mod api; -pub mod web; -pub mod middleware; pub mod errors; +pub mod middleware; +pub mod web; pub use api::create_api_routes; pub use api::create_public_api_routes; diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index eed19218..729a16a0 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -1,11 +1,7 @@ -use axum::{ - routing::get, - Router, - response::Html, -}; -use tower_http::services::ServeDir; -use crate::common::di::AppState; use crate::common::config::AppConfig; +use crate::common::di::AppState; +use axum::{Router, response::Html, routing::get}; +use tower_http::services::ServeDir; /// Creates web routes for serving static files pub fn create_web_routes() -> Router { @@ -20,9 +16,7 @@ pub fn create_web_routes() -> Router { .route("/admin", get(serve_admin_page)) .route("/shared", get(serve_shared_page)) // Serve static files - .fallback_service( - ServeDir::new(static_path) - ) + .fallback_service(ServeDir::new(static_path)) } /// Serve the login page @@ -43,4 +37,4 @@ async fn serve_admin_page() -> Html<&'static str> { /// Serve the shared page async fn serve_shared_page() -> Html<&'static str> { Html(include_str!("../../../static/shared.html")) -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 753de83a..e51779c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,18 @@ // Export the main project modules +pub mod application; pub mod common; pub mod domain; -pub mod application; pub mod infrastructure; pub mod interfaces; // Common public re-exports pub use application::services::folder_service::FolderService; pub use application::services::i18n_application_service::I18nApplicationService; -pub use application::services::storage_mediator::{StorageMediator, FileSystemStorageMediator}; -pub use infrastructure::services::path_service::PathService; +pub use application::services::storage_mediator::{FileSystemStorageMediator, StorageMediator}; pub use domain::services::path_service::StoragePath; -pub use infrastructure::repositories::folder_fs_repository::FolderFsRepository; pub use infrastructure::repositories::CompositeFileRepository; +pub use infrastructure::repositories::folder_fs_repository::FolderFsRepository; pub use infrastructure::repositories::parallel_file_processor::ParallelFileProcessor; pub use infrastructure::services::buffer_pool::BufferPool; -pub use infrastructure::services::compression_service::GzipCompressionService; \ No newline at end of file +pub use infrastructure::services::compression_service::GzipCompressionService; +pub use infrastructure::services::path_service::PathService; diff --git a/src/main.rs b/src/main.rs index 24d0dbb5..d3f5b9f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,174 +1,177 @@ -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; - -use axum::Router; -use axum::extract::DefaultBodyLimit; -use tower_http::trace::TraceLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -/// OxiCloud - Cloud Storage Platform -/// -/// OxiCloud is a NextCloud-like file storage system built in Rust with a focus on -/// performance, security, and clean architecture. The system provides: -/// -/// - File and folder management with rich metadata -/// - User authentication and authorization -/// - File trash system with automatic cleanup -/// - Efficient handling of large files through parallel processing -/// - Compression capabilities for bandwidth optimization -/// - RESTful API and web interface -/// -/// The architecture follows the Clean/Hexagonal Architecture pattern with: -/// -/// - Domain Layer: Core business entities and repository interfaces (domain/*) -/// - Application Layer: Use cases and service orchestration (application/*) -/// - Infrastructure Layer: Technical implementations of repositories (infrastructure/*) -/// - Interface Layer: API endpoints and web controllers (interfaces/*) -/// -/// Dependencies are managed through dependency inversion, with high-level modules -/// defining interfaces (ports) that low-level modules implement (adapters). -/// -/// @author OxiCloud Development Team - -use oxicloud::common; -use oxicloud::infrastructure; -use oxicloud::interfaces; - -use common::di::AppServiceFactory; -use infrastructure::db::create_database_pool; -use interfaces::{create_api_routes, create_public_api_routes, web::create_web_routes}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Initialize tracing - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), - )) - .with(tracing_subscriber::fmt::layer()) - .init(); - - // Load configuration from environment variables - let config = common::config::AppConfig::from_env(); - - // Ensure storage and locales directories exist - let storage_path = config.storage_path.clone(); - if !storage_path.exists() { - std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory"); - } - let locales_path = PathBuf::from("./static/locales"); - if !locales_path.exists() { - std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory"); - } - - // Initialize database pool if auth is enabled - let db_pool = if config.features.enable_auth { - match create_database_pool(&config).await { - Ok(pool) => { - tracing::info!("PostgreSQL database pool initialized successfully"); - Some(Arc::new(pool)) - } - Err(e) => { - tracing::error!("Failed to initialize database pool: {}", e); - None - } - } - } else { - None - }; - - // Build all services via the factory - let factory = AppServiceFactory::with_config( - storage_path, - locales_path, - config.clone(), - ); - - let app_state = factory.build_app_state(db_pool).await - .expect("Failed to build application state. If running in Docker, ensure the storage volume is writable by the oxicloud user (UID 1001)"); - - // Build application router - let api_routes = create_api_routes(&app_state); - let public_api_routes = create_public_api_routes(&app_state); - let web_routes = create_web_routes(); - - let mut app; - - // Build CalDAV / CardDAV / WebDAV protocol routers (merged at top-level, not under /api) - use oxicloud::interfaces::api::handlers::caldav_handler; - use oxicloud::interfaces::api::handlers::carddav_handler; - use oxicloud::interfaces::api::handlers::webdav_handler; - let caldav_router = caldav_handler::caldav_routes(); - let carddav_router = carddav_handler::carddav_routes(); - let webdav_router = webdav_handler::webdav_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; - use oxicloud::interfaces::middleware::auth::auth_middleware; - - let app_state_arc = Arc::new(app_state.clone()); - let auth_router = auth_routes().with_state(app_state_arc.clone()); - - // 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/WebDAV 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.clone(), auth_middleware)); - let webdav_protected = webdav_router - .layer(axum::middleware::from_fn_with_state(app_state_arc, auth_middleware)); - - app = Router::new() - // Auth endpoints (login, register, refresh) are public — no middleware - .nest("/api/auth", auth_router) - // Public API routes (share access, i18n) — no auth required - .nest("/api", public_api_routes) - // All other API routes are protected by auth middleware - .nest("/api", protected_api) - // CalDAV/CardDAV/WebDAV protocols merged at top-level for client compatibility - .merge(caldav_protected) - .merge(carddav_protected) - .merge(webdav_protected) - .merge(web_routes) - .layer(TraceLayer::new_for_http()); - } else { - // Auth disabled — no middleware applied - tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); - app = Router::new() - .nest("/api", public_api_routes) - .nest("/api", api_routes) - // CalDAV/CardDAV/WebDAV protocols merged at top-level - .merge(caldav_router) - .merge(carddav_router) - .merge(webdav_router) - .merge(web_routes) - .layer(TraceLayer::new_for_http()); - } - - // Apply the redirect middleware for legacy routes - use oxicloud::interfaces::middleware::redirect::redirect_middleware; - app = app.layer(axum::middleware::from_fn(redirect_middleware)); - - // Increase the default body limit to 10 GB to allow large file uploads. - // Without this Axum caps Multipart bodies at 2 MB. - app = app.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); - - // Start server - let addr = SocketAddr::from(([0, 0, 0, 0], 8086)); - tracing::info!("Starting OxiCloud server on http://{}", addr); - - let listener = tokio::net::TcpListener::bind(addr).await?; - - // Provide the fully-built state to the router - let app = app.with_state(app_state); - - axum::serve(listener, app).await?; - tracing::info!("Server shutdown completed"); - - Ok(()) -} +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// OxiCloud - Cloud Storage Platform +/// +/// OxiCloud is a NextCloud-like file storage system built in Rust with a focus on +/// performance, security, and clean architecture. The system provides: +/// +/// - File and folder management with rich metadata +/// - User authentication and authorization +/// - File trash system with automatic cleanup +/// - Efficient handling of large files through parallel processing +/// - Compression capabilities for bandwidth optimization +/// - RESTful API and web interface +/// +/// The architecture follows the Clean/Hexagonal Architecture pattern with: +/// +/// - Domain Layer: Core business entities and repository interfaces (domain/*) +/// - Application Layer: Use cases and service orchestration (application/*) +/// - Infrastructure Layer: Technical implementations of repositories (infrastructure/*) +/// - Interface Layer: API endpoints and web controllers (interfaces/*) +/// +/// Dependencies are managed through dependency inversion, with high-level modules +/// defining interfaces (ports) that low-level modules implement (adapters). +/// +/// @author OxiCloud Development Team +use oxicloud::common; +use oxicloud::infrastructure; +use oxicloud::interfaces; + +use common::di::AppServiceFactory; +use infrastructure::db::create_database_pool; +use interfaces::{create_api_routes, create_public_api_routes, web::create_web_routes}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Load configuration from environment variables + let config = common::config::AppConfig::from_env(); + + // Ensure storage and locales directories exist + let storage_path = config.storage_path.clone(); + if !storage_path.exists() { + std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory"); + } + let locales_path = PathBuf::from("./static/locales"); + if !locales_path.exists() { + std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory"); + } + + // Initialize database pool if auth is enabled + let db_pool = if config.features.enable_auth { + match create_database_pool(&config).await { + Ok(pool) => { + tracing::info!("PostgreSQL database pool initialized successfully"); + Some(Arc::new(pool)) + } + Err(e) => { + tracing::error!("Failed to initialize database pool: {}", e); + None + } + } + } else { + None + }; + + // Build all services via the factory + let factory = AppServiceFactory::with_config(storage_path, locales_path, config.clone()); + + let app_state = factory.build_app_state(db_pool).await + .expect("Failed to build application state. If running in Docker, ensure the storage volume is writable by the oxicloud user (UID 1001)"); + + // Build application router + let api_routes = create_api_routes(&app_state); + let public_api_routes = create_public_api_routes(&app_state); + let web_routes = create_web_routes(); + + let mut app; + + // Build CalDAV / CardDAV / WebDAV protocol routers (merged at top-level, not under /api) + use oxicloud::interfaces::api::handlers::caldav_handler; + use oxicloud::interfaces::api::handlers::carddav_handler; + use oxicloud::interfaces::api::handlers::webdav_handler; + let caldav_router = caldav_handler::caldav_routes(); + let carddav_router = carddav_handler::carddav_routes(); + let webdav_router = webdav_handler::webdav_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; + use oxicloud::interfaces::middleware::auth::auth_middleware; + + let app_state_arc = Arc::new(app_state.clone()); + let auth_router = auth_routes().with_state(app_state_arc.clone()); + + // 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/WebDAV 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.clone(), + auth_middleware, + )); + let webdav_protected = webdav_router.layer(axum::middleware::from_fn_with_state( + app_state_arc, + auth_middleware, + )); + + app = Router::new() + // Auth endpoints (login, register, refresh) are public — no middleware + .nest("/api/auth", auth_router) + // Public API routes (share access, i18n) — no auth required + .nest("/api", public_api_routes) + // All other API routes are protected by auth middleware + .nest("/api", protected_api) + // CalDAV/CardDAV/WebDAV protocols merged at top-level for client compatibility + .merge(caldav_protected) + .merge(carddav_protected) + .merge(webdav_protected) + .merge(web_routes) + .layer(TraceLayer::new_for_http()); + } else { + // Auth disabled — no middleware applied + tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); + app = Router::new() + .nest("/api", public_api_routes) + .nest("/api", api_routes) + // CalDAV/CardDAV/WebDAV protocols merged at top-level + .merge(caldav_router) + .merge(carddav_router) + .merge(webdav_router) + .merge(web_routes) + .layer(TraceLayer::new_for_http()); + } + + // Apply the redirect middleware for legacy routes + use oxicloud::interfaces::middleware::redirect::redirect_middleware; + app = app.layer(axum::middleware::from_fn(redirect_middleware)); + + // Increase the default body limit to 10 GB to allow large file uploads. + // Without this Axum caps Multipart bodies at 2 MB. + app = app.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); + + // Start server + let addr = SocketAddr::from(([0, 0, 0, 0], 8086)); + tracing::info!("Starting OxiCloud server on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + + // Provide the fully-built state to the router + let app = app.with_state(app_state); + + axum::serve(listener, app).await?; + tracing::info!("Server shutdown completed"); + + Ok(()) +}