fix: critical bugs from deep audit

- Fix copy_files() data loss: implement real copy_file across full stack
  (FileWritePort, FileManagementUseCase, stubs, service, repository with
  atomic CTE + dedup ref_count increment, batch_operations caller)
- Fix plaintext password in replace_default_admin: hash password via
  PasswordHasherPort before User::new()
- Fix CalendarService hardcoded user_id: unify CalendarUseCase trait with
  explicit user_id parameter on all methods, remove zombie _for_user
  duplicates and hardcoded 'current_user_id', update 20 CalDAV handler
  call sites
- Previous session: migrate DedupService to PostgreSQL (storage.blobs),
  atomic CTEs with compensation for file/folder repository operations
This commit is contained in:
Dionisio
2026-02-14 20:22:19 +01:00
parent 3179e1dd91
commit fac0b5e77b
17 changed files with 2163 additions and 2185 deletions
+34 -57
View File
@@ -106,23 +106,33 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
) -> Result<Vec<CalendarEventDto>, DomainError>; ) -> Result<Vec<CalendarEventDto>, DomainError>;
} }
/// Port for calendar use cases /// Port for calendar use cases.
///
/// All methods require an explicit `user_id` parameter for authorization.
/// The CalDAV protocol handler extracts the user identity from JWT claims
/// and passes it through.
#[async_trait] #[async_trait]
pub trait CalendarUseCase: Send + Sync + 'static { pub trait CalendarUseCase: Send + Sync + 'static {
// Calendar operations // Calendar operations
async fn create_calendar( async fn create_calendar(
&self, &self,
calendar: CreateCalendarDto, calendar: CreateCalendarDto,
user_id: &str,
) -> Result<CalendarDto, DomainError>; ) -> Result<CalendarDto, DomainError>;
async fn update_calendar( async fn update_calendar(
&self, &self,
calendar_id: &str, calendar_id: &str,
update: UpdateCalendarDto, update: UpdateCalendarDto,
user_id: &str,
) -> Result<CalendarDto, DomainError>; ) -> Result<CalendarDto, DomainError>;
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>; async fn delete_calendar(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>;
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError>; async fn get_calendar(
async fn list_my_calendars(&self) -> Result<Vec<CalendarDto>, DomainError>; &self,
async fn list_shared_calendars(&self) -> Result<Vec<CalendarDto>, DomainError>; calendar_id: &str,
user_id: &str,
) -> Result<CalendarDto, DomainError>;
async fn list_my_calendars(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_shared_calendars(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_public_calendars( async fn list_public_calendars(
&self, &self,
limit: Option<i64>, limit: Option<i64>,
@@ -133,90 +143,57 @@ pub trait CalendarUseCase: Send + Sync + 'static {
async fn share_calendar( async fn share_calendar(
&self, &self,
calendar_id: &str, calendar_id: &str,
user_id: &str, target_user_id: &str,
access_level: &str, access_level: &str,
caller_user_id: &str,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
async fn remove_calendar_sharing( async fn remove_calendar_sharing(
&self, &self,
calendar_id: &str, calendar_id: &str,
user_id: &str, target_user_id: &str,
caller_user_id: &str,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
async fn get_calendar_shares( async fn get_calendar_shares(
&self, &self,
calendar_id: &str, calendar_id: &str,
user_id: &str,
) -> Result<Vec<(String, String)>, DomainError>; ) -> Result<Vec<(String, String)>, DomainError>;
// Event operations // Event operations
async fn create_event(&self, event: CreateEventDto) -> Result<CalendarEventDto, DomainError>; async fn create_event(
&self,
event: CreateEventDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError>;
async fn create_event_from_ical( async fn create_event_from_ical(
&self, &self,
event: CreateEventICalDto, event: CreateEventICalDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError>; ) -> Result<CalendarEventDto, DomainError>;
async fn update_event( async fn update_event(
&self, &self,
event_id: &str, event_id: &str,
update: UpdateEventDto, update: UpdateEventDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError>;
async fn delete_event(&self, event_id: &str, user_id: &str) -> Result<(), DomainError>;
async fn get_event(
&self,
event_id: &str,
user_id: &str,
) -> Result<CalendarEventDto, DomainError>; ) -> Result<CalendarEventDto, DomainError>;
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>;
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError>;
async fn list_events( async fn list_events(
&self, &self,
calendar_id: &str, calendar_id: &str,
limit: Option<i64>, limit: Option<i64>,
offset: Option<i64>, offset: Option<i64>,
user_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError>; ) -> Result<Vec<CalendarEventDto>, DomainError>;
async fn get_events_in_range( async fn get_events_in_range(
&self, &self,
calendar_id: &str, calendar_id: &str,
start: DateTime<Utc>, start: DateTime<Utc>,
end: DateTime<Utc>, end: DateTime<Utc>,
) -> Result<Vec<CalendarEventDto>, DomainError>;
// ─── User-contextualized variants (for CalDAV protocol handler) ──
async fn create_calendar_for_user(
&self,
calendar: CreateCalendarDto,
user_id: &str,
) -> Result<CalendarDto, DomainError>;
async fn update_calendar_for_user(
&self,
calendar_id: &str,
update: UpdateCalendarDto,
user_id: &str,
) -> Result<CalendarDto, DomainError>;
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<CalendarDto, DomainError>;
async fn list_my_calendars_for_user(
&self,
user_id: &str,
) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_events_for_user(
&self,
calendar_id: &str,
limit: Option<i64>,
offset: Option<i64>,
user_id: &str, user_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError>; ) -> Result<Vec<CalendarEventDto>, DomainError>;
async fn get_events_in_range_for_user(
&self,
calendar_id: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
user_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError>;
async fn create_event_from_ical_for_user(
&self,
event: CreateEventICalDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError>;
async fn delete_event_for_user(&self, event_id: &str, user_id: &str)
-> Result<(), DomainError>;
} }
+7
View File
@@ -138,6 +138,13 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
folder_id: Option<String>, folder_id: Option<String>,
) -> Result<FileDto, DomainError>; ) -> Result<FileDto, DomainError>;
/// Copies a file to another folder (zero-copy with dedup).
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<FileDto, DomainError>;
/// Renames a file /// Renames a file
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError>; async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError>;
+10
View File
@@ -115,6 +115,16 @@ pub trait FileWritePort: Send + Sync + 'static {
size: u64, size: u64,
) -> Result<(File, PathBuf), DomainError>; ) -> Result<(File, PathBuf), DomainError>;
/// Copies a file to a (possibly different) folder.
///
/// With blob-dedup, this only creates a new metadata row and increments
/// the blob reference count — zero disk I/O for the content.
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<File, DomainError>;
// ── Trash operations ── // ── Trash operations ──
/// Moves a file to the trash /// Moves a file to the trash
@@ -168,7 +168,7 @@ impl AuthApplicationService {
/// Returns whether OIDC is configured and enabled /// Returns whether OIDC is configured and enabled
pub fn oidc_enabled(&self) -> bool { pub fn oidc_enabled(&self) -> bool {
let state = self.oidc.read().unwrap(); let state = self.oidc.read().unwrap();
state.service.is_some() && state.config.as_ref().is_some_and(|c| c.enabled) state.service.is_some() && state.config.as_ref().map_or(false, |c| c.enabled)
} }
/// Returns whether password login is disabled (OIDC-only mode) /// Returns whether password login is disabled (OIDC-only mode)
@@ -177,7 +177,7 @@ impl AuthApplicationService {
state state
.config .config
.as_ref() .as_ref()
.is_some_and(|c| c.disable_password_login) .map_or(false, |c| c.disable_password_login)
} }
/// Returns a clone of the OIDC config if available /// Returns a clone of the OIDC config if available
@@ -664,11 +664,23 @@ impl AuthApplicationService {
// Admin quota, capped to available disk space // Admin quota, capped to available disk space
let admin_quota = self.capped_quota(&admin_role); let admin_quota = self.capped_quota(&admin_role);
// Hash the password (same as register / admin_create_user)
let password_hash = self
.password_hasher
.hash_password(&dto.password)
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"User",
format!("Error hashing password: {}", e),
)
})?;
// Create the new admin user // Create the new admin user
let user = User::new( let user = User::new(
dto.username.clone(), dto.username.clone(),
dto.email.clone(), dto.email.clone(),
dto.password.clone(), password_hash,
admin_role, admin_role,
admin_quota, admin_quota,
) )
+1 -1
View File
@@ -129,7 +129,7 @@ impl BatchOperationService {
// Acquire semaphore permit // Acquire semaphore permit
let permit = semaphore.acquire().await.unwrap(); let permit = semaphore.acquire().await.unwrap();
let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await; let copy_result = mgmt.copy_file(&file_id, target_folder.clone()).await;
// Release the permit explicitly (also released on drop) // Release the permit explicitly (also released on drop)
drop(permit); drop(permit);
+34 -290
View File
@@ -24,13 +24,8 @@ impl CalendarUseCase for CalendarService {
async fn create_calendar( async fn create_calendar(
&self, &self,
calendar: CreateCalendarDto, calendar: CreateCalendarDto,
user_id: &str,
) -> Result<CalendarDto, DomainError> { ) -> Result<CalendarDto, DomainError> {
// 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 self.calendar_storage
.create_calendar(calendar, user_id) .create_calendar(calendar, user_id)
.await .await
@@ -40,20 +35,12 @@ impl CalendarUseCase for CalendarService {
&self, &self,
calendar_id: &str, calendar_id: &str,
update: UpdateCalendarDto, update: UpdateCalendarDto,
user_id: &str,
) -> Result<CalendarDto, DomainError> { ) -> Result<CalendarDto, DomainError> {
// 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
// Check if user has access
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(calendar_id, user_id) .check_calendar_access(calendar_id, user_id)
.await?; .await?;
if !has_access { if !has_access {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -61,21 +48,16 @@ impl CalendarUseCase for CalendarService {
"You don't have permission to update this calendar", "You don't have permission to update this calendar",
)); ));
} }
self.calendar_storage self.calendar_storage
.update_calendar(calendar_id, update) .update_calendar(calendar_id, update)
.await .await
} }
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError> { async fn delete_calendar(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> {
let user_id = "current_user_id"; // This should come from middleware
// Check if user has access
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(calendar_id, user_id) .check_calendar_access(calendar_id, user_id)
.await?; .await?;
if !has_access { if !has_access {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -83,22 +65,19 @@ impl CalendarUseCase for CalendarService {
"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 self.calendar_storage.delete_calendar(calendar_id).await
} }
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError> { async fn get_calendar(
let user_id = "current_user_id"; // This should come from middleware &self,
calendar_id: &str,
// Get the calendar user_id: &str,
) -> Result<CalendarDto, DomainError> {
let calendar = self.calendar_storage.get_calendar(calendar_id).await?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
// Check if user has access or if calendar is public
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(calendar_id, user_id) .check_calendar_access(calendar_id, user_id)
.await?; .await?;
if !has_access && !calendar.is_public { if !has_access && !calendar.is_public {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -106,19 +85,14 @@ impl CalendarUseCase for CalendarService {
"You don't have permission to view this calendar", "You don't have permission to view this calendar",
)); ));
} }
Ok(calendar) Ok(calendar)
} }
async fn list_my_calendars(&self) -> Result<Vec<CalendarDto>, DomainError> { async fn list_my_calendars(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError> {
let user_id = "current_user_id"; // This should come from middleware
self.calendar_storage.list_calendars_by_owner(user_id).await self.calendar_storage.list_calendars_by_owner(user_id).await
} }
async fn list_shared_calendars(&self) -> Result<Vec<CalendarDto>, DomainError> { async fn list_shared_calendars(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError> {
let user_id = "current_user_id"; // This should come from middleware
self.calendar_storage self.calendar_storage
.list_calendars_shared_with_user(user_id) .list_calendars_shared_with_user(user_id)
.await .await
@@ -131,7 +105,6 @@ impl CalendarUseCase for CalendarService {
) -> Result<Vec<CalendarDto>, DomainError> { ) -> Result<Vec<CalendarDto>, DomainError> {
let limit = limit.unwrap_or(100); let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0); let offset = offset.unwrap_or(0);
self.calendar_storage self.calendar_storage
.list_public_calendars(limit, offset) .list_public_calendars(limit, offset)
.await .await
@@ -140,24 +113,18 @@ impl CalendarUseCase for CalendarService {
async fn share_calendar( async fn share_calendar(
&self, &self,
calendar_id: &str, calendar_id: &str,
user_id: &str, target_user_id: &str,
access_level: &str, access_level: &str,
caller_user_id: &str,
) -> Result<(), DomainError> { ) -> 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?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
if calendar.owner_id != caller_user_id {
// Only the owner can share the calendar
if calendar.owner_id != current_user_id {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
"Calendar", "Calendar",
"Only the calendar owner can change sharing settings", "Only the calendar owner can change sharing settings",
)); ));
} }
// Validate access_level
match access_level { match access_level {
"read" | "write" | "owner" => {} "read" | "write" | "owner" => {}
_ => { _ => {
@@ -171,66 +138,55 @@ impl CalendarUseCase for CalendarService {
)); ));
} }
} }
self.calendar_storage self.calendar_storage
.share_calendar(calendar_id, user_id, access_level) .share_calendar(calendar_id, target_user_id, access_level)
.await .await
} }
async fn remove_calendar_sharing( async fn remove_calendar_sharing(
&self, &self,
calendar_id: &str, calendar_id: &str,
user_id: &str, target_user_id: &str,
caller_user_id: &str,
) -> Result<(), DomainError> { ) -> 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?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
if calendar.owner_id != caller_user_id {
// Only the owner can change sharing settings
if calendar.owner_id != current_user_id {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
"Calendar", "Calendar",
"Only the calendar owner can change sharing settings", "Only the calendar owner can change sharing settings",
)); ));
} }
self.calendar_storage self.calendar_storage
.remove_calendar_sharing(calendar_id, user_id) .remove_calendar_sharing(calendar_id, target_user_id)
.await .await
} }
async fn get_calendar_shares( async fn get_calendar_shares(
&self, &self,
calendar_id: &str, calendar_id: &str,
user_id: &str,
) -> Result<Vec<(String, String)>, DomainError> { ) -> Result<Vec<(String, String)>, 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?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
if calendar.owner_id != user_id {
// Only the owner can view sharing settings
if calendar.owner_id != current_user_id {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
"Calendar", "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 self.calendar_storage.get_calendar_shares(calendar_id).await
} }
async fn create_event(&self, event: CreateEventDto) -> Result<CalendarEventDto, DomainError> { async fn create_event(
let user_id = "current_user_id"; // This should come from middleware &self,
event: CreateEventDto,
// Check if user has access to the calendar user_id: &str,
) -> Result<CalendarEventDto, DomainError> {
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(&event.calendar_id, user_id) .check_calendar_access(&event.calendar_id, user_id)
.await?; .await?;
if !has_access { if !has_access {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -238,22 +194,18 @@ impl CalendarUseCase for CalendarService {
"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 self.calendar_storage.create_event(event).await
} }
async fn create_event_from_ical( async fn create_event_from_ical(
&self, &self,
event: CreateEventICalDto, event: CreateEventICalDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError> { ) -> Result<CalendarEventDto, DomainError> {
let user_id = "current_user_id"; // This should come from middleware
// Check if user has access to the calendar
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(&event.calendar_id, user_id) .check_calendar_access(&event.calendar_id, user_id)
.await?; .await?;
if !has_access { if !has_access {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -261,7 +213,6 @@ impl CalendarUseCase for CalendarService {
"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 self.calendar_storage.create_event_from_ical(event).await
} }
@@ -269,18 +220,13 @@ impl CalendarUseCase for CalendarService {
&self, &self,
event_id: &str, event_id: &str,
update: UpdateEventDto, update: UpdateEventDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError> { ) -> Result<CalendarEventDto, DomainError> {
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?; let event = self.calendar_storage.get_event(event_id).await?;
// Check if user has access to the calendar
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(&event.calendar_id, user_id) .check_calendar_access(&event.calendar_id, user_id)
.await?; .await?;
if !has_access { if !has_access {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -288,22 +234,15 @@ impl CalendarUseCase for CalendarService {
"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 self.calendar_storage.update_event(event_id, update).await
} }
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError> { async fn delete_event(&self, event_id: &str, user_id: &str) -> Result<(), DomainError> {
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?; let event = self.calendar_storage.get_event(event_id).await?;
// Check if user has access to the calendar
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(&event.calendar_id, user_id) .check_calendar_access(&event.calendar_id, user_id)
.await?; .await?;
if !has_access { if !has_access {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -311,28 +250,23 @@ impl CalendarUseCase for CalendarService {
"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 self.calendar_storage.delete_event(event_id).await
} }
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError> { async fn get_event(
let user_id = "current_user_id"; // This should come from middleware &self,
event_id: &str,
// Get the event user_id: &str,
) -> Result<CalendarEventDto, DomainError> {
let event = self.calendar_storage.get_event(event_id).await?; let event = self.calendar_storage.get_event(event_id).await?;
// Check if user has access to the calendar
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(&event.calendar_id, user_id) .check_calendar_access(&event.calendar_id, user_id)
.await?; .await?;
// Check if calendar is public
let calendar = self let calendar = self
.calendar_storage .calendar_storage
.get_calendar(&event.calendar_id) .get_calendar(&event.calendar_id)
.await?; .await?;
if !has_access && !calendar.is_public { if !has_access && !calendar.is_public {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -340,7 +274,6 @@ impl CalendarUseCase for CalendarService {
"You don't have permission to view events in this calendar", "You don't have permission to view events in this calendar",
)); ));
} }
Ok(event) Ok(event)
} }
@@ -349,18 +282,13 @@ impl CalendarUseCase for CalendarService {
calendar_id: &str, calendar_id: &str,
limit: Option<i64>, limit: Option<i64>,
offset: Option<i64>, offset: Option<i64>,
user_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError> { ) -> Result<Vec<CalendarEventDto>, DomainError> {
let user_id = "current_user_id"; // This should come from middleware
// Check if user has access to the calendar
let has_access = self let has_access = self
.calendar_storage .calendar_storage
.check_calendar_access(calendar_id, user_id) .check_calendar_access(calendar_id, user_id)
.await?; .await?;
// Check if calendar is public
let calendar = self.calendar_storage.get_calendar(calendar_id).await?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
if !has_access && !calendar.is_public { if !has_access && !calendar.is_public {
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AccessDenied, ErrorKind::AccessDenied,
@@ -368,12 +296,9 @@ impl CalendarUseCase for CalendarService {
"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() { if limit.is_some() || offset.is_some() {
let limit = limit.unwrap_or(100); let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0); let offset = offset.unwrap_or(0);
self.calendar_storage self.calendar_storage
.list_events_by_calendar_paginated(calendar_id, limit, offset) .list_events_by_calendar_paginated(calendar_id, limit, offset)
.await .await
@@ -389,148 +314,6 @@ impl CalendarUseCase for CalendarService {
calendar_id: &str, calendar_id: &str,
start: DateTime<Utc>, start: DateTime<Utc>,
end: DateTime<Utc>, end: DateTime<Utc>,
) -> Result<Vec<CalendarEventDto>, 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?;
// 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",
));
}
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<CalendarDto, DomainError> {
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<CalendarDto, 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 update this calendar",
));
}
self.calendar_storage
.update_calendar(calendar_id, update)
.await
}
async fn delete_calendar_for_user(
&self,
calendar_id: &str,
user_id: &str,
) -> Result<(), DomainError> {
let has_access = self
.calendar_storage
.check_calendar_access(calendar_id, user_id)
.await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Calendar",
"You don't have permission to delete this calendar",
));
}
self.calendar_storage.delete_calendar(calendar_id).await
}
async fn get_calendar_for_user(
&self,
calendar_id: &str,
user_id: &str,
) -> Result<CalendarDto, DomainError> {
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
let has_access = self
.calendar_storage
.check_calendar_access(calendar_id, user_id)
.await?;
if !has_access && !calendar.is_public {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Calendar",
"You don't have permission to view this calendar",
));
}
Ok(calendar)
}
async fn list_my_calendars_for_user(
&self,
user_id: &str,
) -> Result<Vec<CalendarDto>, DomainError> {
self.calendar_storage.list_calendars_by_owner(user_id).await
}
async fn list_events_for_user(
&self,
calendar_id: &str,
limit: Option<i64>,
offset: Option<i64>,
user_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError> {
let has_access = self
.calendar_storage
.check_calendar_access(calendar_id, user_id)
.await?;
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
if !has_access && !calendar.is_public {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Calendar",
"You don't have permission to view events in this calendar",
));
}
if limit.is_some() || offset.is_some() {
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
self.calendar_storage
.list_events_by_calendar_paginated(calendar_id, limit, offset)
.await
} else {
self.calendar_storage
.list_events_by_calendar(calendar_id)
.await
}
}
async fn get_events_in_range_for_user(
&self,
calendar_id: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
user_id: &str, user_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError> { ) -> Result<Vec<CalendarEventDto>, DomainError> {
let has_access = self let has_access = self
@@ -549,43 +332,4 @@ impl CalendarUseCase for CalendarService {
.get_events_in_time_range(calendar_id, &start, &end) .get_events_in_time_range(calendar_id, &start, &end)
.await .await
} }
async fn create_event_from_ical_for_user(
&self,
event: CreateEventICalDto,
user_id: &str,
) -> Result<CalendarEventDto, DomainError> {
let has_access = self
.calendar_storage
.check_calendar_access(&event.calendar_id, user_id)
.await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Calendar",
"You don't have permission to add events to this calendar",
));
}
self.calendar_storage.create_event_from_ical(event).await
}
async fn delete_event_for_user(
&self,
event_id: &str,
user_id: &str,
) -> Result<(), DomainError> {
let event = self.calendar_storage.get_event(event_id).await?;
let has_access = self
.calendar_storage
.check_calendar_access(&event.calendar_id, user_id)
.await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Calendar",
"You don't have permission to delete events in this calendar",
));
}
self.calendar_storage.delete_event(event_id).await
}
} }
@@ -121,6 +121,35 @@ impl FileManagementUseCase for FileManagementService {
Ok(FileDto::from(moved_file)) Ok(FileDto::from(moved_file))
} }
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
info!(
"Copying file with ID: {} to folder: {:?}",
file_id, target_folder_id
);
let copied_file = self
.file_repository
.copy_file(file_id, target_folder_id)
.await
.map_err(|e| {
error!("Error copying file (ID: {}): {}", file_id, e);
e
})?;
info!(
"File copied successfully: {} (ID: {}) to folder: {:?}",
copied_file.name(),
copied_file.id(),
copied_file.folder_id()
);
Ok(FileDto::from(copied_file))
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> { async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> {
info!("Renaming file with ID: {} to \"{}\"", file_id, new_name); info!("Renaming file with ID: {} to \"{}\"", file_id, new_name);
+304 -212
View File
@@ -1,54 +1,58 @@
use sqlx::PgPool;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; 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::admin_settings_service::AdminSettingsService;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository;
use crate::infrastructure::repositories::pg::{
FolderDbRepository, FileBlobReadRepository, FileBlobWriteRepository, TrashDbRepository,
};
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
use crate::infrastructure::services::file_content_cache::{FileContentCache, FileContentCacheConfig};
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::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::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::ContentCachePort; use crate::application::ports::cache_ports::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::chunked_upload_ports::ChunkedUploadPort; use crate::application::ports::chunked_upload_ports::ChunkedUploadPort;
use crate::application::ports::compression_ports::CompressionPort; 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::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::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::pg::{
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, TrashDbRepository,
};
use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository;
use crate::infrastructure::services::file_content_cache::{
FileContentCache, FileContentCacheConfig,
};
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
use crate::common::stubs::{ use crate::common::stubs::{
StubZipPort, StubCompressionPort, StubCompressionPort, StubDedupPort, StubFileManagementUseCase, StubFileReadPort,
StubFileReadPort, StubFileWritePort, StubFolderStoragePort, StubFileRetrievalUseCase, StubFileUploadUseCase, StubFileUseCaseFactory, StubFileWritePort,
StubI18nService, StubFolderUseCase, StubFileUploadUseCase, StubFolderStoragePort, StubFolderUseCase, StubI18nService, StubSearchUseCase, StubZipPort,
StubFileRetrievalUseCase, StubFileManagementUseCase, StubFileUseCaseFactory,
StubSearchUseCase, StubDedupPort,
}; };
/// Factory for the different application components /// Factory for the different application components
/// ///
/// This factory centralizes the creation of all application services, /// This factory centralizes the creation of all application services,
/// ensuring the correct initialization order and resolving circular dependencies. /// ensuring the correct initialization order and resolving circular dependencies.
pub struct AppServiceFactory { pub struct AppServiceFactory {
@@ -66,7 +70,7 @@ impl AppServiceFactory {
config: AppConfig::default(), config: AppConfig::default(),
} }
} }
/// Creates a new service factory with custom configuration /// Creates a new service factory with custom configuration
pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self { pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self {
Self { Self {
@@ -75,72 +79,82 @@ impl AppServiceFactory {
config, config,
} }
} }
/// Gets the configuration /// Gets the configuration
pub fn config(&self) -> &AppConfig { pub fn config(&self) -> &AppConfig {
&self.config &self.config
} }
/// Gets the storage path /// Gets the storage path
pub fn storage_path(&self) -> &PathBuf { pub fn storage_path(&self) -> &PathBuf {
&self.storage_path &self.storage_path
} }
/// Initializes the core system services. /// Initializes the core system services.
/// ///
/// Requires a `PgPool` because `DedupService` stores its index in PostgreSQL. /// Requires a `PgPool` because `DedupService` stores its index in PostgreSQL.
pub async fn create_core_services(&self, db_pool: &Arc<PgPool>) -> Result<CoreServices, DomainError> { pub async fn create_core_services(
&self,
db_pool: &Arc<PgPool>,
) -> Result<CoreServices, DomainError> {
// Path service (still needed for blob storage root + thumbnails) // Path service (still needed for blob storage root + thumbnails)
let path_service = Arc::new(PathService::new(self.storage_path.clone())); let path_service = Arc::new(PathService::new(self.storage_path.clone()));
// File content cache for ultra-fast file serving (hot files in RAM) // File content cache for ultra-fast file serving (hot files in RAM)
let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig { let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig {
max_file_size: 10 * 1024 * 1024, // 10MB max per file max_file_size: 10 * 1024 * 1024, // 10MB max per file
max_total_size: 512 * 1024 * 1024, // 512MB total cache max_total_size: 512 * 1024 * 1024, // 512MB total cache
max_entries: 10000, // Up to 10k files max_entries: 10000, // Up to 10k files
})); }));
tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries"); tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries");
// Thumbnail service for thumbnail generation // Thumbnail service for thumbnail generation
let thumbnail_service = Arc::new( let thumbnail_service = Arc::new(
crate::infrastructure::services::thumbnail_service::ThumbnailService::new( crate::infrastructure::services::thumbnail_service::ThumbnailService::new(
&self.storage_path, &self.storage_path,
5000, // max 5000 thumbnails in cache 5000, // max 5000 thumbnails in cache
100 * 1024 * 1024, // max 100MB cache 100 * 1024 * 1024, // max 100MB cache
) ),
); );
// Initialize thumbnail directories // Initialize thumbnail directories
thumbnail_service.initialize().await?; thumbnail_service.initialize().await?;
// Chunked upload service for large files (>10MB) // Chunked upload service for large files (>10MB)
let chunked_temp_dir = std::path::PathBuf::from(&self.storage_path).join(".uploads"); let chunked_temp_dir = std::path::PathBuf::from(&self.storage_path).join(".uploads");
let chunked_upload_service = Arc::new( 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 // Image transcoding service for automatic WebP conversion
let image_transcode_service = Arc::new( let image_transcode_service = Arc::new(
crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new(
&self.storage_path, &self.storage_path,
2000, // max 2000 transcoded images in cache 2000, // max 2000 transcoded images in cache
50 * 1024 * 1024, // max 50MB in-memory cache 50 * 1024 * 1024, // max 50MB in-memory cache
) ),
); );
image_transcode_service.initialize().await?; image_transcode_service.initialize().await?;
// Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index) // Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index)
let dedup_service = Arc::new( let dedup_service = Arc::new(
crate::infrastructure::services::dedup_service::DedupService::new(&self.storage_path, db_pool.clone()) crate::infrastructure::services::dedup_service::DedupService::new(
&self.storage_path,
db_pool.clone(),
),
); );
dedup_service.initialize().await?; dedup_service.initialize().await?;
// Compression service (gzip) // Compression service (gzip)
let compression_service: Arc<dyn CompressionPort> = Arc::new( let compression_service: Arc<dyn CompressionPort> = Arc::new(
crate::infrastructure::services::compression_service::GzipCompressionService::new() crate::infrastructure::services::compression_service::GzipCompressionService::new(),
); );
tracing::info!("Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage), compression"); tracing::info!(
"Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage), compression"
);
Ok(CoreServices { Ok(CoreServices {
path_service, path_service,
file_content_cache, file_content_cache,
@@ -149,49 +163,56 @@ impl AppServiceFactory {
image_transcode_service, image_transcode_service,
dedup_service, dedup_service,
compression_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(), config: self.config.clone(),
}) })
} }
/// Initializes the repository services (blob-storage model). /// Initializes the repository services (blob-storage model).
/// ///
/// Requires a PgPool since all metadata lives in PostgreSQL. /// Requires a PgPool since all metadata lives in PostgreSQL.
pub fn create_repository_services(&self, core: &CoreServices, db_pool: &Arc<PgPool>) -> RepositoryServices { pub fn create_repository_services(
&self,
core: &CoreServices,
db_pool: &Arc<PgPool>,
) -> RepositoryServices {
// Folder repository — PostgreSQL-backed virtual folders // Folder repository — PostgreSQL-backed virtual folders
let folder_repo_concrete = Arc::new(FolderDbRepository::new(db_pool.clone())); let folder_repo_concrete = Arc::new(FolderDbRepository::new(db_pool.clone()));
let folder_repository: Arc<dyn FolderStoragePort> = folder_repo_concrete.clone(); let folder_repository: Arc<dyn FolderStoragePort> = folder_repo_concrete.clone();
// File repositories — PostgreSQL metadata + blob content via DedupService // File repositories — PostgreSQL metadata + blob content via DedupService
let file_read_repository: Arc<dyn FileReadPort> = Arc::new(FileBlobReadRepository::new( let file_read_repository: Arc<dyn FileReadPort> = Arc::new(FileBlobReadRepository::new(
db_pool.clone(), db_pool.clone(),
core.dedup_service.clone(), core.dedup_service.clone(),
folder_repo_concrete.clone(), folder_repo_concrete.clone(),
)); ));
let file_write_repository: Arc<dyn FileWritePort> = Arc::new(FileBlobWriteRepository::new( let file_write_repository: Arc<dyn FileWritePort> = Arc::new(FileBlobWriteRepository::new(
db_pool.clone(), db_pool.clone(),
core.dedup_service.clone(), core.dedup_service.clone(),
folder_repo_concrete.clone(), folder_repo_concrete.clone(),
)); ));
// I18n repository // I18n repository
let i18n_repository = Arc::new(FileSystemI18nService::new( let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone()));
self.locales_path.clone()
));
// Trash repository — reads soft-delete flags from storage.files/folders // Trash repository — reads soft-delete flags from storage.files/folders
let trash_repository = if core.config.features.enable_trash { let trash_repository = if core.config.features.enable_trash {
Some(Arc::new(TrashDbRepository::new( Some(Arc::new(TrashDbRepository::new(
db_pool.clone(), db_pool.clone(),
core.config.storage.trash_retention_days, core.config.storage.trash_retention_days,
)) as Arc<dyn crate::domain::repositories::trash_repository::TrashRepository>) ))
as Arc<
dyn crate::domain::repositories::trash_repository::TrashRepository,
>)
} else { } else {
None None
}; };
tracing::info!("Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)"); tracing::info!(
"Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)"
);
RepositoryServices { RepositoryServices {
folder_repository, folder_repository,
folder_repo_concrete, folder_repo_concrete,
@@ -201,7 +222,7 @@ impl AppServiceFactory {
trash_repository, trash_repository,
} }
} }
/// Initializes the application services /// Initializes the application services
pub fn create_application_services( pub fn create_application_services(
&self, &self,
@@ -210,23 +231,21 @@ impl AppServiceFactory {
trash_service: Option<Arc<dyn TrashUseCase>>, trash_service: Option<Arc<dyn TrashUseCase>>,
) -> ApplicationServices { ) -> ApplicationServices {
// Main services // Main services
let folder_service = Arc::new(FolderService::new( let folder_service = Arc::new(FolderService::new(repos.folder_repository.clone()));
repos.folder_repository.clone()
));
// Refactored services with all infrastructure ports // Refactored services with all infrastructure ports
// In blob model, dedup is handled by the repository — no separate write-behind needed // In blob model, dedup is handled by the repository — no separate write-behind needed
let file_upload_service = Arc::new(FileUploadService::new_with_read( let file_upload_service = Arc::new(FileUploadService::new_with_read(
repos.file_write_repository.clone(), repos.file_write_repository.clone(),
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
)); ));
let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache(
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
core.file_content_cache.clone(), core.file_content_cache.clone(),
core.image_transcode_service.clone(), core.image_transcode_service.clone(),
)); ));
// FileManagementService with dedup and trash // FileManagementService with dedup and trash
let file_management_service = Arc::new(FileManagementService::new_full( let file_management_service = Arc::new(FileManagementService::new_full(
repos.file_write_repository.clone(), repos.file_write_repository.clone(),
@@ -234,26 +253,24 @@ impl AppServiceFactory {
trash_service.clone(), trash_service.clone(),
core.dedup_service.clone(), core.dedup_service.clone(),
)); ));
let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new(
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
repos.file_write_repository.clone() repos.file_write_repository.clone(),
)); ));
let i18n_service = Arc::new(I18nApplicationService::new( let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone()));
repos.i18n_repository.clone()
));
// Search service with cache // Search service with cache
let search_service: Option<Arc<dyn SearchUseCase>> = Some(Arc::new(SearchService::new( let search_service: Option<Arc<dyn SearchUseCase>> = Some(Arc::new(SearchService::new(
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
repos.folder_repository.clone(), repos.folder_repository.clone(),
300, // Cache TTL in seconds (5 minutes) 300, // Cache TTL in seconds (5 minutes)
1000, // Maximum cache entries 1000, // Maximum cache entries
))); )));
tracing::info!("Application services initialized"); tracing::info!("Application services initialized");
ApplicationServices { ApplicationServices {
// Concrete types for handlers that need them // Concrete types for handlers that need them
folder_service_concrete: folder_service.clone(), folder_service_concrete: folder_service.clone(),
@@ -266,12 +283,12 @@ impl AppServiceFactory {
i18n_service, i18n_service,
trash_service, // Already set via parameter trash_service, // Already set via parameter
search_service, 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 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 /// Creates the trash service
pub async fn create_trash_service( pub async fn create_trash_service(
&self, &self,
@@ -281,9 +298,9 @@ impl AppServiceFactory {
tracing::info!("Trash service is disabled in configuration"); tracing::info!("Trash service is disabled in configuration");
return None; return None;
} }
let trash_repo = repos.trash_repository.as_ref()?; let trash_repo = repos.trash_repository.as_ref()?;
// Wire ports directly to TrashService — no adapter layer needed // Wire ports directly to TrashService — no adapter layer needed
let service = Arc::new(TrashService::new( let service = Arc::new(TrashService::new(
trash_repo.clone(), trash_repo.clone(),
@@ -292,20 +309,20 @@ impl AppServiceFactory {
repos.folder_repository.clone(), repos.folder_repository.clone(),
self.config.storage.trash_retention_days, self.config.storage.trash_retention_days,
)); ));
// Initialize cleanup service // Initialize cleanup service
let cleanup_service = TrashCleanupService::new( let cleanup_service = TrashCleanupService::new(
service.clone(), service.clone(),
trash_repo.clone(), trash_repo.clone(),
24, // Run cleanup every 24 hours 24, // Run cleanup every 24 hours
); );
cleanup_service.start_cleanup_job().await; cleanup_service.start_cleanup_job().await;
tracing::info!("Trash service initialized with daily cleanup schedule"); tracing::info!("Trash service initialized with daily cleanup schedule");
Some(service as Arc<dyn TrashUseCase>) Some(service as Arc<dyn TrashUseCase>)
} }
/// Creates the sharing service /// Creates the sharing service
pub fn create_share_service( pub fn create_share_service(
&self, &self,
@@ -315,11 +332,9 @@ impl AppServiceFactory {
tracing::info!("File sharing service is disabled in configuration"); tracing::info!("File sharing service is disabled in configuration");
return None; return None;
} }
let share_repository = Arc::new(ShareFsRepository::new( let share_repository = Arc::new(ShareFsRepository::new(Arc::new(self.config.clone())));
Arc::new(self.config.clone())
));
// Build a password hasher for share password verification // Build a password hasher for share password verification
let password_hasher: Arc<dyn crate::application::ports::auth_ports::PasswordHasherPort> = let password_hasher: Arc<dyn crate::application::ports::auth_ports::PasswordHasherPort> =
Arc::new(crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new()); Arc::new(crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new());
@@ -331,44 +346,37 @@ impl AppServiceFactory {
repos.folder_repository.clone(), repos.folder_repository.clone(),
password_hasher, password_hasher,
)); ));
tracing::info!("File sharing service initialized"); tracing::info!("File sharing service initialized");
Some(service) Some(service)
} }
/// Creates the favorites service (requires database) /// Creates the favorites service (requires database)
pub fn create_favorites_service( pub fn create_favorites_service(&self, db_pool: &Arc<PgPool>) -> Arc<dyn FavoritesUseCase> {
&self,
db_pool: &Arc<PgPool>,
) -> Arc<dyn FavoritesUseCase> {
let repo = Arc::new( 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)); let service = Arc::new(FavoritesService::new(repo));
tracing::info!("Favorites service initialized"); tracing::info!("Favorites service initialized");
service service
} }
/// Creates the recent items service (requires database) /// Creates the recent items service (requires database)
pub fn create_recent_service( pub fn create_recent_service(&self, db_pool: &Arc<PgPool>) -> Arc<dyn RecentItemsUseCase> {
&self,
db_pool: &Arc<PgPool>,
) -> Arc<dyn RecentItemsUseCase> {
let repo = Arc::new( 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( let service = Arc::new(RecentService::new(
repo, repo, 50, // Maximum recent items per user
50 // Maximum recent items per user
)); ));
tracing::info!("Recent items service initialized"); tracing::info!("Recent items service initialized");
service service
} }
/// Preloads translations /// Preloads translations
pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) { pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) {
use crate::domain::services::i18n_service::Locale; use crate::domain::services::i18n_service::Locale;
if let Err(e) = i18n_service.load_translations(Locale::English).await { if let Err(e) = i18n_service.load_translations(Locale::English).await {
tracing::warn!("Failed to load English translations: {}", e); tracing::warn!("Failed to load English translations: {}", e);
} }
@@ -394,13 +402,13 @@ impl AppServiceFactory {
db_pool: &Arc<PgPool>, db_pool: &Arc<PgPool>,
) -> Arc<dyn crate::application::ports::storage_ports::StorageUsagePort> { ) -> Arc<dyn crate::application::ports::storage_ports::StorageUsagePort> {
let user_repository = Arc::new( 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( let service = Arc::new(
crate::application::services::storage_usage_service::StorageUsageService::new( crate::application::services::storage_usage_service::StorageUsageService::new(
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
user_repository, user_repository,
) ),
); );
tracing::info!("Storage usage service initialized"); tracing::info!("Storage usage service initialized");
service service
@@ -415,7 +423,10 @@ impl AppServiceFactory {
) -> Result<AppState, DomainError> { ) -> Result<AppState, DomainError> {
// Database is REQUIRED in 100% blob storage model // Database is REQUIRED in 100% blob storage model
let pool = db_pool.clone().ok_or_else(|| { let pool = db_pool.clone().ok_or_else(|| {
DomainError::internal_error("Database", "PostgreSQL database is required for blob storage model") DomainError::internal_error(
"Database",
"PostgreSQL database is required for blob storage model",
)
})?; })?;
// 1. Core services (PgPool needed for DedupService index) // 1. Core services (PgPool needed for DedupService index)
@@ -437,7 +448,9 @@ impl AppServiceFactory {
// 6. Database-dependent services (PgPool always available in blob model) // 6. Database-dependent services (PgPool always available in blob model)
let favorites_service: Option<Arc<dyn FavoritesUseCase>>; let favorites_service: Option<Arc<dyn FavoritesUseCase>>;
let recent_service: Option<Arc<dyn RecentItemsUseCase>>; let recent_service: Option<Arc<dyn RecentItemsUseCase>>;
let storage_usage_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>; let storage_usage_service: Option<
Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>,
>;
let mut auth_services: Option<crate::common::di::AuthServices> = None; let mut auth_services: Option<crate::common::di::AuthServices> = None;
{ {
@@ -457,7 +470,9 @@ impl AppServiceFactory {
&self.config, &self.config,
pool.clone(), pool.clone(),
Some(apps.folder_service_concrete.clone()), Some(apps.folder_service_concrete.clone()),
).await { )
.await
{
Ok(services) => { Ok(services) => {
tracing::info!("Authentication services initialized successfully"); tracing::info!("Authentication services initialized successfully");
auth_services = Some(services); auth_services = Some(services);
@@ -477,7 +492,7 @@ impl AppServiceFactory {
crate::infrastructure::services::zip_service::ZipService::new( crate::infrastructure::services::zip_service::ZipService::new(
apps.file_retrieval_service.clone(), apps.file_retrieval_service.clone(),
apps.folder_service.clone(), apps.folder_service.clone(),
) ),
); );
let mut core = core; let mut core = core;
core.zip_service = zip_service; core.zip_service = zip_service;
@@ -501,11 +516,11 @@ impl AppServiceFactory {
addressbook_use_case: None, addressbook_use_case: None,
contact_use_case: None, contact_use_case: None,
}; };
// 9b. Wire admin settings service when auth is available // 9b. Wire admin settings service when auth is available
if let Some(auth_svc) = &app_state.auth_service { if let Some(auth_svc) = &app_state.auth_service {
let settings_repo = Arc::new( 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(); let server_base_url = self.config.base_url();
@@ -521,34 +536,50 @@ impl AppServiceFactory {
// Hot-reload OIDC from DB settings if configured // Hot-reload OIDC from DB settings if configured
match admin_svc.load_effective_oidc_config().await { match admin_svc.load_effective_oidc_config().await {
Ok(eff) if eff.enabled && !eff.issuer_url.is_empty() Ok(eff)
&& !eff.client_id.is_empty() && !eff.client_secret.is_empty() => if eff.enabled
&& !eff.issuer_url.is_empty()
&& !eff.client_id.is_empty()
&& !eff.client_secret.is_empty() =>
{ {
let oidc_svc = Arc::new( 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); auth_svc.auth_application_service.reload_oidc(oidc_svc, eff);
tracing::info!("OIDC config loaded from admin settings (database)"); tracing::info!("OIDC config loaded from admin settings (database)");
} }
Ok(_) => { 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) => { 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); app_state.admin_settings_service = Some(admin_svc);
} }
// 10. Wire CalDAV/CardDAV services // 10. Wire CalDAV/CardDAV services
{ {
// CalDAV // CalDAV
let calendar_repo: Arc<dyn crate::domain::repositories::calendar_repository::CalendarRepository> = Arc::new( let calendar_repo: Arc<
crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()) dyn crate::domain::repositories::calendar_repository::CalendarRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()),
); );
let event_repo: Arc<dyn crate::domain::repositories::calendar_event_repository::CalendarEventRepository> = Arc::new( let event_repo: Arc<
crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(pool.clone()) dyn crate::domain::repositories::calendar_event_repository::CalendarEventRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(
pool.clone(),
),
); );
let calendar_storage = Arc::new( let calendar_storage = Arc::new(
crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new( crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new(
@@ -557,19 +588,32 @@ impl AppServiceFactory {
) )
); );
let calendar_service = Arc::new( 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<dyn crate::application::ports::calendar_ports::CalendarUseCase>); app_state.calendar_use_case = Some(
calendar_service
as Arc<dyn crate::application::ports::calendar_ports::CalendarUseCase>,
);
// CardDAV // CardDAV
let address_book_repo: Arc<dyn crate::domain::repositories::address_book_repository::AddressBookRepository> = Arc::new( let address_book_repo: Arc<
crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()) dyn crate::domain::repositories::address_book_repository::AddressBookRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()),
); );
let contact_repo: Arc<dyn crate::domain::repositories::contact_repository::ContactRepository> = Arc::new( let contact_repo: Arc<
crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()) dyn crate::domain::repositories::contact_repository::ContactRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()),
); );
let group_repo: Arc<dyn crate::domain::repositories::contact_repository::ContactGroupRepository> = Arc::new( let group_repo: Arc<
crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(pool.clone()) dyn crate::domain::repositories::contact_repository::ContactGroupRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(
pool.clone(),
),
); );
let contact_storage = Arc::new( let contact_storage = Arc::new(
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
@@ -578,9 +622,13 @@ impl AppServiceFactory {
group_repo, group_repo,
) )
); );
app_state.addressbook_use_case = Some(contact_storage.clone() as Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>); app_state.addressbook_use_case = Some(contact_storage.clone()
app_state.contact_use_case = Some(contact_storage as Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>); as Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>);
app_state.contact_use_case = Some(
contact_storage
as Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>,
);
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
} }
@@ -610,7 +658,8 @@ pub struct RepositoryServices {
pub file_read_repository: Arc<dyn FileReadPort>, pub file_read_repository: Arc<dyn FileReadPort>,
pub file_write_repository: Arc<dyn FileWritePort>, pub file_write_repository: Arc<dyn FileWritePort>,
pub i18n_repository: Arc<dyn I18nService>, pub i18n_repository: Arc<dyn I18nService>,
pub trash_repository: Option<Arc<dyn crate::domain::repositories::trash_repository::TrashRepository>>, pub trash_repository:
Option<Arc<dyn crate::domain::repositories::trash_repository::TrashRepository>>,
} }
/// Container for application services /// Container for application services
@@ -652,11 +701,14 @@ pub struct AppState {
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>, pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>, pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>, pub recent_service: Option<Arc<dyn RecentItemsUseCase>>,
pub storage_usage_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>, pub storage_usage_service:
Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>,
pub calendar_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUseCase>>, pub calendar_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUseCase>>,
pub contact_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUseCase>>, pub contact_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUseCase>>,
pub calendar_use_case: Option<Arc<dyn crate::application::ports::calendar_ports::CalendarUseCase>>, pub calendar_use_case:
pub addressbook_use_case: Option<Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>>, Option<Arc<dyn crate::application::ports::calendar_ports::CalendarUseCase>>,
pub addressbook_use_case:
Option<Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>>,
pub contact_use_case: Option<Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>>, pub contact_use_case: Option<Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>>,
} }
@@ -668,16 +720,22 @@ impl Default for AppState {
let config = crate::common::config::AppConfig::default(); let config = crate::common::config::AppConfig::default();
let path_service = Arc::new( let path_service = Arc::new(
crate::infrastructure::services::path_service::PathService::new( crate::infrastructure::services::path_service::PathService::new(
std::path::PathBuf::from("./storage") std::path::PathBuf::from("./storage"),
) ),
); );
let i18n_repository = Arc::new(StubI18nService) as Arc<dyn crate::domain::services::i18n_service::I18nService>; let i18n_repository = Arc::new(StubI18nService)
let folder_service = Arc::new(StubFolderUseCase) as Arc<dyn crate::application::ports::inbound::FolderUseCase>; as Arc<dyn crate::domain::services::i18n_service::I18nService>;
let file_upload_service = Arc::new(StubFileUploadUseCase) as Arc<dyn crate::application::ports::file_ports::FileUploadUseCase>; let folder_service = Arc::new(StubFolderUseCase)
let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) as Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>; as Arc<dyn crate::application::ports::inbound::FolderUseCase>;
let file_management_service = Arc::new(StubFileManagementUseCase) as Arc<dyn crate::application::ports::file_ports::FileManagementUseCase>; let file_upload_service = Arc::new(StubFileUploadUseCase)
let file_use_case_factory = Arc::new(StubFileUseCaseFactory) as Arc<dyn crate::application::ports::file_ports::FileUseCaseFactory>; as Arc<dyn crate::application::ports::file_ports::FileUploadUseCase>;
let file_retrieval_service = Arc::new(StubFileRetrievalUseCase)
as Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>;
let file_management_service = Arc::new(StubFileManagementUseCase)
as Arc<dyn crate::application::ports::file_ports::FileManagementUseCase>;
let file_use_case_factory = Arc::new(StubFileUseCaseFactory)
as Arc<dyn crate::application::ports::file_ports::FileUseCaseFactory>;
// Create file content cache for stub // Create file content cache for stub
let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig::default())); let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig::default()));
@@ -688,14 +746,14 @@ impl Default for AppState {
&std::path::PathBuf::from("./storage"), &std::path::PathBuf::from("./storage"),
100, 100,
10 * 1024 * 1024, 10 * 1024 * 1024,
) ),
); );
// Create dummy chunked upload service // Create dummy chunked upload service
let dummy_chunked_upload_service: Arc<dyn ChunkedUploadPort> = Arc::new( let dummy_chunked_upload_service: Arc<dyn ChunkedUploadPort> = Arc::new(
crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::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 // Create dummy image transcode service
@@ -704,7 +762,7 @@ impl Default for AppState {
&std::path::PathBuf::from("./storage"), &std::path::PathBuf::from("./storage"),
100, 100,
10 * 1024 * 1024, 10 * 1024 * 1024,
) ),
); );
// Stub dedup service (Default is only used for routing stubs, never for real I/O) // Stub dedup service (Default is only used for routing stubs, never for real I/O)
@@ -729,22 +787,28 @@ impl Default for AppState {
// Repository services using stubs // Repository services using stubs
let repository_services = RepositoryServices { let repository_services = RepositoryServices {
folder_repository: Arc::new(StubFolderStoragePort) as Arc<dyn crate::application::ports::outbound::FolderStoragePort>, folder_repository: Arc::new(StubFolderStoragePort)
as Arc<dyn crate::application::ports::outbound::FolderStoragePort>,
folder_repo_concrete: dummy_folder_repo_concrete, folder_repo_concrete: dummy_folder_repo_concrete,
file_read_repository: Arc::new(StubFileReadPort) as Arc<dyn crate::application::ports::storage_ports::FileReadPort>, file_read_repository: Arc::new(StubFileReadPort)
file_write_repository: Arc::new(StubFileWritePort) as Arc<dyn crate::application::ports::storage_ports::FileWritePort>, as Arc<dyn crate::application::ports::storage_ports::FileReadPort>,
file_write_repository: Arc::new(StubFileWritePort)
as Arc<dyn crate::application::ports::storage_ports::FileWritePort>,
i18n_repository, i18n_repository,
trash_repository: None, trash_repository: None,
}; };
// Dummy concrete services for compatibility // Dummy concrete services for compatibility
let dummy_folder_storage = Arc::new(StubFolderStoragePort) as Arc<dyn crate::application::ports::outbound::FolderStoragePort>; let dummy_folder_storage = Arc::new(StubFolderStoragePort)
as Arc<dyn crate::application::ports::outbound::FolderStoragePort>;
let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage)); let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage));
// Dummy I18nApplicationService // Dummy I18nApplicationService
let dummy_i18n_app_service = crate::application::services::i18n_application_service::I18nApplicationService::new( let dummy_i18n_app_service =
Arc::new(StubI18nService) as Arc<dyn crate::domain::services::i18n_service::I18nService> crate::application::services::i18n_application_service::I18nApplicationService::new(
); Arc::new(StubI18nService)
as Arc<dyn crate::domain::services::i18n_service::I18nService>,
);
// Application services using stubs // Application services using stubs
let application_services = ApplicationServices { let application_services = ApplicationServices {
@@ -756,7 +820,8 @@ impl Default for AppState {
file_use_case_factory, file_use_case_factory,
i18n_service: Arc::new(dummy_i18n_app_service), i18n_service: Arc::new(dummy_i18n_app_service),
trash_service: None, trash_service: None,
search_service: Some(Arc::new(StubSearchUseCase) as Arc<dyn crate::application::ports::inbound::SearchUseCase>), search_service: Some(Arc::new(StubSearchUseCase)
as Arc<dyn crate::application::ports::inbound::SearchUseCase>),
share_service: None, share_service: None,
favorites_service: None, favorites_service: None,
recent_service: None, recent_service: None,
@@ -808,12 +873,12 @@ impl AppState {
contact_use_case: None, contact_use_case: None,
} }
} }
pub fn with_database(mut self, db_pool: Arc<PgPool>) -> Self { pub fn with_database(mut self, db_pool: Arc<PgPool>) -> Self {
self.db_pool = Some(db_pool); self.db_pool = Some(db_pool);
self self
} }
/// Creates a minimal AppState for route construction. /// Creates a minimal AppState for route construction.
/// ///
/// Uses `Default` stubs for infrastructure services, then overlays the real /// Uses `Default` stubs for infrastructure services, then overlays the real
@@ -821,11 +886,15 @@ impl AppState {
/// This keeps `routes.rs` free of any `crate::infrastructure` references. /// This keeps `routes.rs` free of any `crate::infrastructure` references.
pub fn for_routing( pub fn for_routing(
folder_service: Arc<FolderService>, folder_service: Arc<FolderService>,
file_retrieval_service: Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>, file_retrieval_service: Arc<
dyn crate::application::ports::file_ports::FileRetrievalUseCase,
>,
file_upload_service: Arc<dyn FileUploadUseCase>, file_upload_service: Arc<dyn FileUploadUseCase>,
file_management_service: Arc<dyn FileManagementUseCase>, file_management_service: Arc<dyn FileManagementUseCase>,
folder_use_case: Arc<dyn crate::application::ports::inbound::FolderUseCase>, folder_use_case: Arc<dyn crate::application::ports::inbound::FolderUseCase>,
i18n_service: Option<Arc<crate::application::services::i18n_application_service::I18nApplicationService>>, i18n_service: Option<
Arc<crate::application::services::i18n_application_service::I18nApplicationService>,
>,
trash_service: Option<Arc<dyn TrashUseCase>>, trash_service: Option<Arc<dyn TrashUseCase>>,
search_service: Option<Arc<dyn crate::application::ports::inbound::SearchUseCase>>, search_service: Option<Arc<dyn crate::application::ports::inbound::SearchUseCase>>,
share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>, share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
@@ -833,98 +902,121 @@ impl AppState {
recent_service: Option<Arc<dyn RecentItemsUseCase>>, recent_service: Option<Arc<dyn RecentItemsUseCase>>,
) -> Self { ) -> Self {
let mut state = Self::default(); let mut state = Self::default();
// Override application services with real ones // Override application services with real ones
state.applications.folder_service_concrete = folder_service.clone(); state.applications.folder_service_concrete = folder_service.clone();
state.applications.folder_service = folder_use_case; state.applications.folder_service = folder_use_case;
state.applications.file_upload_service = file_upload_service; state.applications.file_upload_service = file_upload_service;
state.applications.file_retrieval_service = file_retrieval_service.clone(); state.applications.file_retrieval_service = file_retrieval_service.clone();
state.applications.file_management_service = file_management_service; state.applications.file_management_service = file_management_service;
if let Some(i18n) = i18n_service { if let Some(i18n) = i18n_service {
state.applications.i18n_service = i18n; state.applications.i18n_service = i18n;
} }
state.applications.trash_service = trash_service.clone(); state.applications.trash_service = trash_service.clone();
state.applications.search_service = search_service.clone(); state.applications.search_service = search_service.clone();
state.applications.share_service = share_service.clone(); state.applications.share_service = share_service.clone();
state.applications.favorites_service = favorites_service.clone(); state.applications.favorites_service = favorites_service.clone();
state.applications.recent_service = recent_service.clone(); state.applications.recent_service = recent_service.clone();
// Also set top-level optional services // Also set top-level optional services
state.trash_service = trash_service; state.trash_service = trash_service;
state.share_service = share_service; state.share_service = share_service;
state.favorites_service = favorites_service; state.favorites_service = favorites_service;
state.recent_service = recent_service; state.recent_service = recent_service;
// Create real ZipService with the actual file/folder services // Create real ZipService with the actual file/folder services
state.core.zip_service = Arc::new( state.core.zip_service = Arc::new(
crate::infrastructure::services::zip_service::ZipService::new( crate::infrastructure::services::zip_service::ZipService::new(
file_retrieval_service as Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>, file_retrieval_service
folder_service.clone() as Arc<dyn crate::application::ports::inbound::FolderUseCase>, as Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>,
) folder_service.clone()
as Arc<dyn crate::application::ports::inbound::FolderUseCase>,
),
); );
state state
} }
pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self { pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self {
self.auth_service = Some(auth_services); self.auth_service = Some(auth_services);
self self
} }
pub fn with_trash_service(mut self, trash_service: Arc<dyn TrashUseCase>) -> Self { pub fn with_trash_service(mut self, trash_service: Arc<dyn TrashUseCase>) -> Self {
self.trash_service = Some(trash_service); self.trash_service = Some(trash_service);
self self
} }
pub fn with_share_service(mut self, share_service: Arc<dyn crate::application::ports::share_ports::ShareUseCase>) -> Self { pub fn with_share_service(
mut self,
share_service: Arc<dyn crate::application::ports::share_ports::ShareUseCase>,
) -> Self {
self.share_service = Some(share_service); self.share_service = Some(share_service);
self self
} }
pub fn with_favorites_service(mut self, favorites_service: Arc<dyn FavoritesUseCase>) -> Self { pub fn with_favorites_service(mut self, favorites_service: Arc<dyn FavoritesUseCase>) -> Self {
self.favorites_service = Some(favorites_service); self.favorites_service = Some(favorites_service);
self self
} }
pub fn with_recent_service(mut self, recent_service: Arc<dyn RecentItemsUseCase>) -> Self { pub fn with_recent_service(mut self, recent_service: Arc<dyn RecentItemsUseCase>) -> Self {
self.recent_service = Some(recent_service); self.recent_service = Some(recent_service);
self self
} }
pub fn with_storage_usage_service(mut self, storage_usage_service: Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>) -> Self { pub fn with_storage_usage_service(
mut self,
storage_usage_service: Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>,
) -> Self {
self.storage_usage_service = Some(storage_usage_service); self.storage_usage_service = Some(storage_usage_service);
self self
} }
pub fn with_calendar_service(mut self, calendar_service: Arc<dyn crate::application::ports::storage_ports::StorageUseCase>) -> Self { pub fn with_calendar_service(
mut self,
calendar_service: Arc<dyn crate::application::ports::storage_ports::StorageUseCase>,
) -> Self {
self.calendar_service = Some(calendar_service); self.calendar_service = Some(calendar_service);
self self
} }
pub fn with_contact_service(mut self, contact_service: Arc<dyn crate::application::ports::storage_ports::StorageUseCase>) -> Self { pub fn with_contact_service(
mut self,
contact_service: Arc<dyn crate::application::ports::storage_ports::StorageUseCase>,
) -> Self {
self.contact_service = Some(contact_service); self.contact_service = Some(contact_service);
self self
} }
pub fn with_calendar_use_case(mut self, calendar_use_case: Arc<dyn crate::application::ports::calendar_ports::CalendarUseCase>) -> Self { pub fn with_calendar_use_case(
mut self,
calendar_use_case: Arc<dyn crate::application::ports::calendar_ports::CalendarUseCase>,
) -> Self {
self.calendar_use_case = Some(calendar_use_case); self.calendar_use_case = Some(calendar_use_case);
self self
} }
pub fn with_addressbook_use_case(mut self, addressbook_use_case: Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>) -> Self { pub fn with_addressbook_use_case(
mut self,
addressbook_use_case: Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>,
) -> Self {
self.addressbook_use_case = Some(addressbook_use_case); self.addressbook_use_case = Some(addressbook_use_case);
self self
} }
pub fn with_contact_use_case(mut self, contact_use_case: Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>) -> Self { pub fn with_contact_use_case(
mut self,
contact_use_case: Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>,
) -> Self {
self.contact_use_case = Some(contact_use_case); self.contact_use_case = Some(contact_use_case);
self self
} }
pub fn with_zip_service(mut self, zip_service: Arc<dyn ZipPort>) -> Self { pub fn with_zip_service(mut self, zip_service: Arc<dyn ZipPort>) -> Self {
self.core.zip_service = zip_service; self.core.zip_service = zip_service;
self self
} }
} }
+16
View File
@@ -168,6 +168,14 @@ impl FileWritePort for StubFileWritePort {
Ok(File::default()) Ok(File::default())
} }
async fn copy_file(
&self,
_file_id: &str,
_target_folder_id: Option<String>,
) -> Result<File, DomainError> {
Ok(File::default())
}
async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<File, DomainError> { async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<File, DomainError> {
Ok(File::default()) Ok(File::default())
} }
@@ -483,6 +491,14 @@ impl FileManagementUseCase for StubFileManagementUseCase {
Ok(FileDto::default()) Ok(FileDto::default())
} }
async fn copy_file(
&self,
_file_id: &str,
_folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<FileDto, DomainError> { async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<FileDto, DomainError> {
Ok(FileDto::default()) Ok(FileDto::default())
} }
+2 -2
View File
@@ -5,6 +5,6 @@ pub mod pg;
// Re-exportar para facilitar acceso // Re-exportar para facilitar acceso
pub use pg::{ pub use pg::{
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, SessionPgRepository,
SessionPgRepository, TrashDbRepository, UserPgRepository, TrashDbRepository, UserPgRepository,
}; };
@@ -1,271 +1,265 @@
//! PostgreSQL + Blob-backed file read repository. //! PostgreSQL + Blob-backed file read repository.
//! //!
//! Implements `FileReadPort` using: //! Implements `FileReadPort` using:
//! - `storage.files` table for metadata lookups //! - `storage.files` table for metadata lookups
//! - `DedupPort` for reading content-addressable blobs from the filesystem //! - `DedupPort` for reading content-addressable blobs from the filesystem
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use futures::Stream; use futures::Stream;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::dedup_ports::DedupPort; use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::storage_ports::FileReadPort; use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::file::File; use crate::domain::entities::file::File;
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath; use crate::domain::services::path_service::StoragePath;
use super::folder_db_repository::FolderDbRepository; use super::folder_db_repository::FolderDbRepository;
/// File read repository backed by PostgreSQL metadata + blob storage. /// File read repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobReadRepository { pub struct FileBlobReadRepository {
pool: Arc<PgPool>, pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>, dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>, folder_repo: Arc<FolderDbRepository>,
} }
impl FileBlobReadRepository { impl FileBlobReadRepository {
pub fn new( pub fn new(
pool: Arc<PgPool>, pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>, dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>, folder_repo: Arc<FolderDbRepository>,
) -> Self { ) -> Self {
Self { Self {
pool, pool,
dedup, dedup,
folder_repo, folder_repo,
} }
} }
/// Build a virtual StoragePath for a file. /// Build a virtual StoragePath for a file.
async fn build_file_path( async fn build_file_path(
&self, &self,
folder_id: Option<&str>, folder_id: Option<&str>,
file_name: &str, file_name: &str,
) -> Result<StoragePath, DomainError> { ) -> Result<StoragePath, DomainError> {
if let Some(fid) = folder_id { if let Some(fid) = folder_id {
let folder_path = self.folder_repo.get_folder_path(fid).await?; let folder_path = self.folder_repo.get_folder_path(fid).await?;
Ok(folder_path.join(file_name)) Ok(folder_path.join(file_name))
} else { } else {
Ok(StoragePath::from_string(file_name)) Ok(StoragePath::from_string(file_name))
} }
} }
/// Convert a database row into a `File` domain entity. /// Convert a database row into a `File` domain entity.
async fn row_to_file( async fn row_to_file(
&self, &self,
id: String, id: String,
name: String, name: String,
folder_id: Option<String>, folder_id: Option<String>,
size: i64, size: i64,
mime_type: String, mime_type: String,
created_at: i64, created_at: i64,
modified_at: i64, modified_at: i64,
) -> Result<File, DomainError> { ) -> Result<File, DomainError> {
let storage_path = self let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
.build_file_path(folder_id.as_deref(), &name) File::with_timestamps(
.await?; id,
File::with_timestamps( name,
id, storage_path,
name, size as u64,
storage_path, mime_type,
size as u64, folder_id,
mime_type, created_at as u64,
folder_id, modified_at as u64,
created_at as u64, )
modified_at as u64, .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
) }
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
} /// Get the blob hash for a file.
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
/// Get the blob hash for a file. sqlx::query_scalar::<_, String>(
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> { "SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
sqlx::query_scalar::<_, String>( )
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed", .bind(file_id)
) .fetch_optional(self.pool.as_ref())
.bind(file_id) .await
.fetch_optional(self.pool.as_ref()) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))?
.await .ok_or_else(|| DomainError::not_found("File", file_id))
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))? }
.ok_or_else(|| DomainError::not_found("File", file_id)) }
}
} #[async_trait]
impl FileReadPort for FileBlobReadRepository {
#[async_trait] async fn get_file(&self, id: &str) -> Result<File, DomainError> {
impl FileReadPort for FileBlobReadRepository { let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
async fn get_file(&self, id: &str) -> Result<File, DomainError> { r#"
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>( SELECT id::text, name, folder_id::text, size, mime_type,
r#" EXTRACT(EPOCH FROM created_at)::bigint,
SELECT id::text, name, folder_id::text, size, mime_type, EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM created_at)::bigint, FROM storage.files
EXTRACT(EPOCH FROM updated_at)::bigint WHERE id = $1::uuid AND NOT is_trashed
FROM storage.files "#,
WHERE id = $1::uuid AND NOT is_trashed )
"#, .bind(id)
) .fetch_optional(self.pool.as_ref())
.bind(id) .await
.fetch_optional(self.pool.as_ref()) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))?
.await .ok_or_else(|| DomainError::not_found("File", id))?;
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?; self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) }
.await
} async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
async fn list_files( if let Some(fid) = folder_id {
&self, sqlx::query_as(
folder_id: Option<&str>, r#"
) -> Result<Vec<File>, DomainError> { SELECT id::text, name, folder_id::text, size, mime_type,
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> = EXTRACT(EPOCH FROM created_at)::bigint,
if let Some(fid) = folder_id { EXTRACT(EPOCH FROM updated_at)::bigint
sqlx::query_as( FROM storage.files
r#" WHERE folder_id = $1::uuid AND NOT is_trashed
SELECT id::text, name, folder_id::text, size, mime_type, ORDER BY name
EXTRACT(EPOCH FROM created_at)::bigint, "#,
EXTRACT(EPOCH FROM updated_at)::bigint )
FROM storage.files .bind(fid)
WHERE folder_id = $1::uuid AND NOT is_trashed .fetch_all(self.pool.as_ref())
ORDER BY name .await
"#, } else {
) sqlx::query_as(
.bind(fid) r#"
.fetch_all(self.pool.as_ref()) SELECT id::text, name, folder_id::text, size, mime_type,
.await EXTRACT(EPOCH FROM created_at)::bigint,
} else { EXTRACT(EPOCH FROM updated_at)::bigint
sqlx::query_as( FROM storage.files
r#" WHERE folder_id IS NULL AND NOT is_trashed
SELECT id::text, name, folder_id::text, size, mime_type, ORDER BY name
EXTRACT(EPOCH FROM created_at)::bigint, "#,
EXTRACT(EPOCH FROM updated_at)::bigint )
FROM storage.files .fetch_all(self.pool.as_ref())
WHERE folder_id IS NULL AND NOT is_trashed .await
ORDER BY name }
"#, .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
)
.fetch_all(self.pool.as_ref()) let mut files = Vec::with_capacity(rows.len());
.await for (id, name, fid, size, mime, ca, ma) in rows {
} files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?);
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; }
Ok(files)
let mut files = Vec::with_capacity(rows.len()); }
for (id, name, fid, size, mime, ca, ma) in rows {
files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?); async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
} let blob_hash = self.get_blob_hash(id).await?;
Ok(files) self.dedup.read_blob(&blob_hash).await
} }
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> { async fn get_file_stream(
let blob_hash = self.get_blob_hash(id).await?; &self,
self.dedup.read_blob(&blob_hash).await id: &str,
} ) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
// Read blob as bytes and wrap in a single-chunk stream.
async fn get_file_stream( // For very large files, a true streaming implementation from the
&self, // blob file would be better, but DedupPort API currently returns bytes.
id: &str, let blob_hash = self.get_blob_hash(id).await?;
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> { let content = self.dedup.read_blob_bytes(&blob_hash).await?;
// Read blob as bytes and wrap in a single-chunk stream.
// For very large files, a true streaming implementation from the let stream = futures::stream::once(async move { Ok(content) });
// blob file would be better, but DedupPort API currently returns bytes. Ok(Box::new(stream))
let blob_hash = self.get_blob_hash(id).await?; }
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
async fn get_file_range_stream(
let stream = futures::stream::once(async move { Ok(content) }); &self,
Ok(Box::new(stream)) id: &str,
} start: u64,
end: Option<u64>,
async fn get_file_range_stream( ) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
&self, let blob_hash = self.get_blob_hash(id).await?;
id: &str, let content = self.dedup.read_blob_bytes(&blob_hash).await?;
start: u64,
end: Option<u64>, let start = start as usize;
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> { let end = end.map_or(content.len(), |e| e as usize).min(content.len());
let blob_hash = self.get_blob_hash(id).await?;
let content = self.dedup.read_blob_bytes(&blob_hash).await?; if start >= content.len() {
return Ok(Box::new(futures::stream::empty()));
let start = start as usize; }
let end = end.map_or(content.len(), |e| e as usize).min(content.len());
let slice = content.slice(start..end);
if start >= content.len() { let stream = futures::stream::once(async move { Ok(slice) });
return Ok(Box::new(futures::stream::empty())); Ok(Box::new(stream))
} }
let slice = content.slice(start..end); async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
let stream = futures::stream::once(async move { Ok(slice) }); let blob_hash = self.get_blob_hash(id).await?;
Ok(Box::new(stream)) self.dedup.read_blob_bytes(&blob_hash).await
} }
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> { async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
let blob_hash = self.get_blob_hash(id).await?; let row = sqlx::query_as::<_, (String, Option<String>)>(
self.dedup.read_blob_bytes(&blob_hash).await r#"
} SELECT name, folder_id::text
FROM storage.files
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> { WHERE id = $1::uuid AND NOT is_trashed
let row = sqlx::query_as::<_, (String, Option<String>)>( "#,
r#" )
SELECT name, folder_id::text .bind(id)
FROM storage.files .fetch_optional(self.pool.as_ref())
WHERE id = $1::uuid AND NOT is_trashed .await
"#, .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
) .ok_or_else(|| DomainError::not_found("File", id))?;
.bind(id)
.fetch_optional(self.pool.as_ref()) self.build_file_path(row.1.as_deref(), &row.0).await
.await }
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?; async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
// Walk the path to find the parent folder, searching by folder names
self.build_file_path(row.1.as_deref(), &row.0).await let path = path.trim_start_matches('/').trim_end_matches('/');
} let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> { if segments.is_empty() {
// Walk the path to find the parent folder, searching by folder names return Err(DomainError::not_found("Folder", "empty path"));
let path = path.trim_start_matches('/').trim_end_matches('/'); }
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
// For path "a/b/c/file.txt", the parent folder path is "a/b/c"
if segments.is_empty() { // But we don't know which part is folders vs filename.
return Err(DomainError::not_found("Folder", "empty path")); // Walk segments trying to find matching folders.
} let mut current_parent: Option<String> = None;
// For path "a/b/c/file.txt", the parent folder path is "a/b/c" for segment in &segments {
// But we don't know which part is folders vs filename. let row = if let Some(ref pid) = current_parent {
// Walk segments trying to find matching folders. sqlx::query_as::<_, (String,)>(
let mut current_parent: Option<String> = None; r#"
SELECT id::text FROM storage.folders
for segment in &segments { WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
let row = if let Some(ref pid) = current_parent { "#,
sqlx::query_as::<_, (String,)>( )
r#" .bind(segment)
SELECT id::text FROM storage.folders .bind(pid)
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed .fetch_optional(self.pool.as_ref())
"#, .await
) } else {
.bind(segment) sqlx::query_as::<_, (String,)>(
.bind(pid) r#"
.fetch_optional(self.pool.as_ref()) SELECT id::text FROM storage.folders
.await WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
} else { "#,
sqlx::query_as::<_, (String,)>( )
r#" .bind(segment)
SELECT id::text FROM storage.folders .fetch_optional(self.pool.as_ref())
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed .await
"#, }
) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
.bind(segment)
.fetch_optional(self.pool.as_ref()) match row {
.await Some(r) => current_parent = Some(r.0),
} None => break, // This segment is not a folder → it's the filename
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?; }
}
match row {
Some(r) => current_parent = Some(r.0), current_parent
None => break, // This segment is not a folder → it's the filename .ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
} }
} }
current_parent.ok_or_else(|| {
DomainError::not_found("Folder", format!("parent for path: {path}"))
})
}
}
@@ -1,435 +1,526 @@
//! PostgreSQL + Blob-backed file write repository. //! PostgreSQL + Blob-backed file write repository.
//! //!
//! Implements `FileWritePort` using: //! Implements `FileWritePort` using:
//! - `storage.files` table for metadata //! - `storage.files` table for metadata
//! - `DedupPort` for content-addressable blob storage on the filesystem //! - `DedupPort` for content-addressable blob storage on the filesystem
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use futures::Stream; use futures::Stream;
use sqlx::PgPool; use sqlx::PgPool;
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::dedup_ports::DedupPort; use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::storage_ports::FileWritePort; use crate::application::ports::storage_ports::FileWritePort;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::file::File; use crate::domain::entities::file::File;
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath; use crate::domain::services::path_service::StoragePath;
use super::folder_db_repository::FolderDbRepository; use super::folder_db_repository::FolderDbRepository;
/// File write repository backed by PostgreSQL metadata + blob storage. /// File write repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobWriteRepository { pub struct FileBlobWriteRepository {
pool: Arc<PgPool>, pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>, dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>, folder_repo: Arc<FolderDbRepository>,
} }
impl FileBlobWriteRepository { impl FileBlobWriteRepository {
pub fn new( pub fn new(
pool: Arc<PgPool>, pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>, dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>, folder_repo: Arc<FolderDbRepository>,
) -> Self { ) -> Self {
Self { Self {
pool, pool,
dedup, dedup,
folder_repo, folder_repo,
} }
} }
/// Build a virtual StoragePath for a file from its DB metadata. /// Build a virtual StoragePath for a file from its DB metadata.
async fn build_file_path( async fn build_file_path(
&self, &self,
folder_id: Option<&str>, folder_id: Option<&str>,
file_name: &str, file_name: &str,
) -> Result<StoragePath, DomainError> { ) -> Result<StoragePath, DomainError> {
if let Some(fid) = folder_id { if let Some(fid) = folder_id {
let folder_path = self.folder_repo.get_folder_path(fid).await?; let folder_path = self.folder_repo.get_folder_path(fid).await?;
Ok(folder_path.join(file_name)) Ok(folder_path.join(file_name))
} else { } else {
Ok(StoragePath::from_string(file_name)) Ok(StoragePath::from_string(file_name))
} }
} }
/// Convert a database row into a `File` domain entity. /// Convert a database row into a `File` domain entity.
async fn row_to_file( async fn row_to_file(
&self, &self,
id: String, id: String,
name: String, name: String,
folder_id: Option<String>, folder_id: Option<String>,
size: i64, size: i64,
mime_type: String, mime_type: String,
created_at: i64, created_at: i64,
modified_at: i64, modified_at: i64,
) -> Result<File, DomainError> { ) -> Result<File, DomainError> {
let storage_path = self let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
.build_file_path(folder_id.as_deref(), &name) File::with_timestamps(
.await?; id,
File::with_timestamps( name,
id, storage_path,
name, size as u64,
storage_path, mime_type,
size as u64, folder_id,
mime_type, created_at as u64,
folder_id, modified_at as u64,
created_at as u64, )
modified_at as u64, .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
) }
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
} /// Derive user_id from the parent folder, or error if folder_id is None.
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<String, DomainError> {
/// Derive user_id from the parent folder, or error if folder_id is None. match folder_id {
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<String, DomainError> { Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
match folder_id { None => Err(DomainError::internal_error(
Some(fid) => self.folder_repo.get_folder_user_id(fid).await, "FileBlobWrite",
None => Err(DomainError::internal_error( "folder_id is required to determine file owner",
"FileBlobWrite", )),
"folder_id is required to determine file owner", }
)), }
} }
}
} #[async_trait]
impl FileWritePort for FileBlobWriteRepository {
#[async_trait] async fn save_file(
impl FileWritePort for FileBlobWriteRepository { &self,
async fn save_file( name: String,
&self, folder_id: Option<String>,
name: String, content_type: String,
folder_id: Option<String>, content: Vec<u8>,
content_type: String, ) -> Result<File, DomainError> {
content: Vec<u8>, let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
) -> Result<File, DomainError> { let size = content.len() as i64;
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
let size = content.len() as i64; // Store content in blob store
let dedup_result = self
// Store content in blob store .dedup
let dedup_result = self .store_bytes(&content, Some(content_type.clone()))
.dedup .await?;
.store_bytes(&content, Some(content_type.clone())) let blob_hash = dedup_result.hash().to_string();
.await?;
let blob_hash = dedup_result.hash().to_string(); // Insert file metadata — if this fails, compensate by removing the blob ref
let row = match sqlx::query_as::<_, (String, i64, i64)>(
// Insert file metadata r#"
let row = sqlx::query_as::<_, (String, i64, i64)>( INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
r#" VALUES ($1, $2::uuid, $3, $4, $5, $6)
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) RETURNING id::text,
VALUES ($1, $2::uuid, $3, $4, $5, $6) EXTRACT(EPOCH FROM created_at)::bigint,
RETURNING id::text, EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM created_at)::bigint, "#,
EXTRACT(EPOCH FROM updated_at)::bigint )
"#, .bind(&name)
) .bind(&folder_id)
.bind(&name) .bind(&user_id)
.bind(&folder_id) .bind(&blob_hash)
.bind(&user_id) .bind(size)
.bind(&blob_hash) .bind(&content_type)
.bind(size) .fetch_one(self.pool.as_ref())
.bind(&content_type) .await
.fetch_one(self.pool.as_ref()) {
.await Ok(row) => row,
.map_err(|e| { Err(e) => {
if let sqlx::Error::Database(ref db_err) = e { // ── Compensation: undo the blob ref so it doesn't become orphaned ──
if db_err.code().as_deref() == Some("23505") { if let Err(rollback_err) = self.dedup.remove_reference(&blob_hash).await {
return DomainError::already_exists( tracing::error!(
"File", "Blob orphaned after failed INSERT — hash: {}, err: {}",
format!("{name} already exists in folder"), &blob_hash[..12],
); rollback_err
} );
} }
DomainError::internal_error("FileBlobWrite", format!("insert: {e}")) if let sqlx::Error::Database(ref db_err) = e {
})?; if db_err.code().as_deref() == Some("23505") {
return Err(DomainError::already_exists(
tracing::info!( "File",
"💾 BLOB WRITE: {} ({} bytes, hash: {})", format!("{name} already exists in folder"),
name, ));
size, }
&blob_hash[..12] }
); return Err(DomainError::internal_error(
"FileBlobWrite",
self.row_to_file( format!("insert: {e}"),
row.0, ));
name, }
folder_id, };
size,
content_type, tracing::info!(
row.1, "💾 BLOB WRITE: {} ({} bytes, hash: {})",
row.2, name,
) size,
.await &blob_hash[..12]
} );
async fn save_file_from_stream( self.row_to_file(row.0, name, folder_id, size, content_type, row.1, row.2)
&self, .await
name: String, }
folder_id: Option<String>,
content_type: String, async fn save_file_from_stream(
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>, &self,
) -> Result<File, DomainError> { name: String,
use futures::StreamExt; folder_id: Option<String>,
content_type: String,
// Collect stream into bytes (blobs are content-addressed, need full content for hash) stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
let mut content = Vec::new(); ) -> Result<File, DomainError> {
let mut stream = stream; use futures::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| { // Collect stream into bytes (blobs are content-addressed, need full content for hash)
DomainError::internal_error("FileBlobWrite", format!("stream read: {e}")) let mut content = Vec::new();
})?; let mut stream = stream;
content.extend_from_slice(&chunk); while let Some(chunk) = stream.next().await {
} let chunk = chunk.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("stream read: {e}"))
self.save_file(name, folder_id, content_type, content).await })?;
} content.extend_from_slice(&chunk);
}
async fn move_file(
&self, self.save_file(name, folder_id, content_type, content).await
file_id: &str, }
target_folder_id: Option<String>,
) -> Result<File, DomainError> { async fn move_file(
// If moving to a different folder, get the new user_id (must be same user) &self,
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>( file_id: &str,
r#" target_folder_id: Option<String>,
UPDATE storage.files ) -> Result<File, DomainError> {
SET folder_id = $1::uuid, updated_at = NOW() // If moving to a different folder, get the new user_id (must be same user)
WHERE id = $2::uuid AND NOT is_trashed let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
RETURNING id::text, name, folder_id::text, size, mime_type, r#"
EXTRACT(EPOCH FROM created_at)::bigint, UPDATE storage.files
EXTRACT(EPOCH FROM updated_at)::bigint SET folder_id = $1::uuid, updated_at = NOW()
"#, WHERE id = $2::uuid AND NOT is_trashed
) RETURNING id::text, name, folder_id::text, size, mime_type,
.bind(&target_folder_id) EXTRACT(EPOCH FROM created_at)::bigint,
.bind(file_id) EXTRACT(EPOCH FROM updated_at)::bigint
.fetch_optional(self.pool.as_ref()) "#,
.await )
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))? .bind(&target_folder_id)
.ok_or_else(|| DomainError::not_found("File", file_id))?; .bind(file_id)
.fetch_optional(self.pool.as_ref())
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) .await
.await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
} .ok_or_else(|| DomainError::not_found("File", file_id))?;
async fn rename_file( self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
&self, .await
file_id: &str, }
new_name: &str,
) -> Result<File, DomainError> { async fn copy_file(
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>( &self,
r#" file_id: &str,
UPDATE storage.files target_folder_id: Option<String>,
SET name = $1, updated_at = NOW() ) -> Result<File, DomainError> {
WHERE id = $2::uuid AND NOT is_trashed // Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count.
RETURNING id::text, name, folder_id::text, size, mime_type, // Single round-trip; blob content is NOT copied (dedup makes this zero-copy).
EXTRACT(EPOCH FROM created_at)::bigint, let target_fid = target_folder_id.clone();
EXTRACT(EPOCH FROM updated_at)::bigint
"#, let row = sqlx::query_as::<
) _,
.bind(new_name) (
.bind(file_id) String,
.fetch_optional(self.pool.as_ref()) String,
.await Option<String>,
.map_err(|e| { i64,
if let sqlx::Error::Database(ref db_err) = e { String,
if db_err.code().as_deref() == Some("23505") { i64,
return DomainError::already_exists( i64,
"File", String,
format!("{new_name} already exists"), ),
); >(
} r#"
} WITH src AS (
DomainError::internal_error("FileBlobWrite", format!("rename: {e}")) SELECT name, folder_id, user_id, blob_hash, size, mime_type
})? FROM storage.files
.ok_or_else(|| DomainError::not_found("File", file_id))?; WHERE id = $1::uuid AND NOT is_trashed
),
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) new_file AS (
.await INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
} SELECT name,
COALESCE($2::uuid, folder_id),
async fn delete_file(&self, id: &str) -> Result<(), DomainError> { user_id,
// Get blob_hash before deleting so we can decrement ref blob_hash,
let hash = sqlx::query_scalar::<_, String>( size,
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid", mime_type
) FROM src
.bind(id) RETURNING id::text, name, folder_id::text, size, mime_type,
.fetch_optional(self.pool.as_ref()) EXTRACT(EPOCH FROM created_at)::bigint,
.await EXTRACT(EPOCH FROM updated_at)::bigint,
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("hash lookup: {e}")))?; blob_hash
)
let result = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid") SELECT * FROM new_file
.bind(id) "#,
.execute(self.pool.as_ref()) )
.await .bind(file_id)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?; .bind(&target_fid)
.fetch_optional(self.pool.as_ref())
if result.rows_affected() == 0 { .await
return Err(DomainError::not_found("File", id)); .map_err(|e| {
} if let sqlx::Error::Database(ref db_err) = e {
if db_err.code().as_deref() == Some("23505") {
// Decrement blob reference return DomainError::already_exists(
if let Some(h) = hash { "File",
if let Err(e) = self.dedup.remove_reference(&h).await { "File with that name already exists in target folder".to_string(),
tracing::warn!("Failed to decrement blob ref for {}: {}", &h[..12], e); );
} }
} }
DomainError::internal_error("FileBlobWrite", format!("copy: {e}"))
Ok(()) })?
} .ok_or_else(|| DomainError::not_found("File", file_id))?;
async fn update_file_content( let blob_hash = &row.7;
&self,
file_id: &str, // Increment blob reference count (best-effort; INSERT already succeeded)
content: Vec<u8>, if let Err(e) = self.dedup.add_reference(blob_hash).await {
) -> Result<(), DomainError> { tracing::warn!(
// Get old blob hash to decrement ref "Failed to increment blob ref for copy {}: {}",
let old_hash = sqlx::query_scalar::<_, String>( &blob_hash[..12],
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid", e
) );
.bind(file_id) }
.fetch_optional(self.pool.as_ref())
.await tracing::info!(
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("old hash: {e}")))? "📋 BLOB COPY: {} (hash: {}, zero-copy via dedup)",
.ok_or_else(|| DomainError::not_found("File", file_id))?; row.1,
&blob_hash[..12]
// Store new content );
let new_size = content.len() as i64;
let dedup_result = self.dedup.store_bytes(&content, None).await?; self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
let new_hash = dedup_result.hash().to_string(); .await
}
// Update file metadata
sqlx::query( async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
r#" let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
UPDATE storage.files r#"
SET blob_hash = $1, size = $2, updated_at = NOW() UPDATE storage.files
WHERE id = $3::uuid SET name = $1, updated_at = NOW()
"#, WHERE id = $2::uuid AND NOT is_trashed
) RETURNING id::text, name, folder_id::text, size, mime_type,
.bind(&new_hash) EXTRACT(EPOCH FROM created_at)::bigint,
.bind(new_size) EXTRACT(EPOCH FROM updated_at)::bigint
.bind(file_id) "#,
.execute(self.pool.as_ref()) )
.await .bind(new_name)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("update: {e}")))?; .bind(file_id)
.fetch_optional(self.pool.as_ref())
// Decrement old blob ref (only if hash changed) .await
if old_hash != new_hash { .map_err(|e| {
if let Err(e) = self.dedup.remove_reference(&old_hash).await { if let sqlx::Error::Database(ref db_err) = e {
tracing::warn!( if db_err.code().as_deref() == Some("23505") {
"Failed to decrement old blob ref {}: {}", return DomainError::already_exists(
&old_hash[..12], "File",
e format!("{new_name} already exists"),
); );
} }
} }
DomainError::internal_error("FileBlobWrite", format!("rename: {e}"))
Ok(()) })?
} .ok_or_else(|| DomainError::not_found("File", file_id))?;
async fn register_file_deferred( self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
&self, .await
name: String, }
folder_id: Option<String>,
content_type: String, async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
size: u64, // Atomic DELETE RETURNING — one round-trip instead of SELECT + DELETE
) -> Result<(File, PathBuf), DomainError> { let hash = sqlx::query_scalar::<_, String>(
let user_id = self.resolve_user_id(folder_id.as_deref()).await?; "DELETE FROM storage.files WHERE id = $1::uuid RETURNING blob_hash",
)
// For deferred registration we use a placeholder hash. .bind(id)
// The write-behind cache will call update_file_content later. .fetch_optional(self.pool.as_ref())
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000"; .await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?
let row = sqlx::query_as::<_, (String, i64, i64)>( .ok_or_else(|| DomainError::not_found("File", id))?;
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) // Decrement blob reference (best-effort after successful DELETE)
VALUES ($1, $2::uuid, $3, $4, $5, $6) if let Err(e) = self.dedup.remove_reference(&hash).await {
RETURNING id::text, tracing::warn!("Failed to decrement blob ref for {}: {}", &hash[..12], e);
EXTRACT(EPOCH FROM created_at)::bigint, }
EXTRACT(EPOCH FROM updated_at)::bigint
"#, Ok(())
) }
.bind(&name)
.bind(&folder_id) async fn update_file_content(
.bind(&user_id) &self,
.bind(placeholder_hash) file_id: &str,
.bind(size as i64) content: Vec<u8>,
.bind(&content_type) ) -> Result<(), DomainError> {
.fetch_one(self.pool.as_ref()) // Store new content first (blob store is idempotent)
.await let new_size = content.len() as i64;
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?; let dedup_result = self.dedup.store_bytes(&content, None).await?;
let new_hash = dedup_result.hash().to_string();
let file = self
.row_to_file( // Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
row.0.clone(), // The `old` CTE locks + reads the row *before* the update touches it.
name, let old_hash = match sqlx::query_scalar::<_, String>(
folder_id, r#"
size as i64, WITH old AS (
content_type, SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
row.1, )
row.2, UPDATE storage.files f
) SET blob_hash = $1, size = $2, updated_at = NOW()
.await?; FROM old
WHERE f.id = old.id
// The target_path is not meaningful for blob storage (content goes to .blobs/) RETURNING old.blob_hash
// but the WriteBehindCache API requires it. We return a synthetic path. "#,
let target_path = PathBuf::from(format!(".pending/{}", row.0)); )
.bind(&new_hash)
Ok((file, target_path)) .bind(new_size)
} .bind(file_id)
.fetch_optional(self.pool.as_ref())
// ── Trash operations ── .await
{
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { Ok(Some(old)) => old,
let result = sqlx::query( Ok(None) => {
r#" // File not found — compensate: remove the new blob ref
UPDATE storage.files if let Err(e) = self.dedup.remove_reference(&new_hash).await {
SET is_trashed = TRUE, tracing::error!("Blob orphaned after missing file: {}", e);
trashed_at = NOW(), }
original_folder_id = folder_id, return Err(DomainError::not_found("File", file_id));
updated_at = NOW() }
WHERE id = $1::uuid AND NOT is_trashed Err(e) => {
"#, // UPDATE failed — compensate: remove the new blob ref
) if let Err(rollback_err) = self.dedup.remove_reference(&new_hash).await {
.bind(file_id) tracing::error!(
.execute(self.pool.as_ref()) "Blob orphaned after failed UPDATE — hash: {}, err: {}",
.await &new_hash[..12],
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?; rollback_err
);
if result.rows_affected() == 0 { }
return Err(DomainError::not_found("File", file_id)); return Err(DomainError::internal_error(
} "FileBlobWrite",
Ok(()) format!("update: {e}"),
} ));
}
async fn restore_from_trash( };
&self,
file_id: &str, // Decrement old blob ref (only if hash changed, best-effort)
_original_path: &str, if old_hash != new_hash {
) -> Result<(), DomainError> { if let Err(e) = self.dedup.remove_reference(&old_hash).await {
let result = sqlx::query( tracing::warn!(
r#" "Failed to decrement old blob ref {}: {}",
UPDATE storage.files &old_hash[..12],
SET is_trashed = FALSE, e
trashed_at = NULL, );
folder_id = COALESCE(original_folder_id, folder_id), }
original_folder_id = NULL, }
updated_at = NOW()
WHERE id = $1::uuid AND is_trashed Ok(())
"#, }
)
.bind(file_id) async fn register_file_deferred(
.execute(self.pool.as_ref()) &self,
.await name: String,
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?; folder_id: Option<String>,
content_type: String,
if result.rows_affected() == 0 { size: u64,
return Err(DomainError::not_found("File", file_id)); ) -> Result<(File, PathBuf), DomainError> {
} let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
Ok(())
} // For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> { let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
// Same as delete_file — removes from DB and decrements blob ref
self.delete_file(file_id).await let row = sqlx::query_as::<_, (String, i64, i64)>(
} r#"
} INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
VALUES ($1, $2::uuid, $3, $4, $5, $6)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(&user_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
let file = self
.row_to_file(
row.0.clone(),
name,
folder_id,
size as i64,
content_type,
row.1,
row.2,
)
.await?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)
// but the WriteBehindCache API requires it. We return a synthetic path.
let target_path = PathBuf::from(format!(".pending/{}", row.0));
Ok((file, target_path))
}
// ── Trash operations ──
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> {
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = TRUE,
trashed_at = NOW(),
original_folder_id = folder_id,
updated_at = NOW()
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}
async fn restore_from_trash(
&self,
file_id: &str,
_original_path: &str,
) -> Result<(), DomainError> {
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = FALSE,
trashed_at = NULL,
folder_id = COALESCE(original_folder_id, folder_id),
original_folder_id = NULL,
updated_at = NOW()
WHERE id = $1::uuid AND is_trashed
"#,
)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> {
// Same as delete_file — removes from DB and decrements blob ref
self.delete_file(file_id).await
}
}
File diff suppressed because it is too large Load Diff
@@ -1,191 +1,173 @@
//! PostgreSQL-backed trash repository. //! PostgreSQL-backed trash repository.
//! //!
//! Implements `TrashRepository` using soft-delete columns in `storage.files` //! Implements `TrashRepository` using soft-delete columns in `storage.files`
//! and `storage.folders`. There is no separate trash table — trashed items //! and `storage.folders`. There is no separate trash table — trashed items
//! are files/folders with `is_trashed = TRUE`. //! are files/folders with `is_trashed = TRUE`.
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::common::errors::{DomainError, Result}; use crate::common::errors::{DomainError, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::repositories::trash_repository::TrashRepository;
/// Default retention period (days) used when computing deletion_date. /// Default retention period (days) used when computing deletion_date.
const _DEFAULT_RETENTION_DAYS: i64 = 30; const _DEFAULT_RETENTION_DAYS: i64 = 30;
/// PostgreSQL-backed trash repository using soft-delete flags. /// PostgreSQL-backed trash repository using soft-delete flags.
pub struct TrashDbRepository { pub struct TrashDbRepository {
pool: Arc<PgPool>, pool: Arc<PgPool>,
retention_days: i64, retention_days: i64,
} }
impl TrashDbRepository { impl TrashDbRepository {
pub fn new(pool: Arc<PgPool>, retention_days: u32) -> Self { pub fn new(pool: Arc<PgPool>, retention_days: u32) -> Self {
Self { Self {
pool, pool,
retention_days: retention_days as i64, retention_days: retention_days as i64,
} }
} }
/// Convert a trash_items view row into a TrashedItem entity. /// Convert a trash_items view row into a TrashedItem entity.
fn row_to_trashed_item( fn row_to_trashed_item(
&self, &self,
id: Uuid, id: Uuid,
name: String, name: String,
item_type: String, item_type: String,
user_id: String, user_id: String,
trashed_at: Option<DateTime<Utc>>, trashed_at: Option<DateTime<Utc>>,
) -> TrashedItem { ) -> TrashedItem {
let trashed_at = trashed_at.unwrap_or_else(Utc::now); let trashed_at = trashed_at.unwrap_or_else(Utc::now);
let deletion_date = trashed_at + chrono::Duration::days(self.retention_days); let deletion_date = trashed_at + chrono::Duration::days(self.retention_days);
let item_type_enum = match item_type.as_str() { let item_type_enum = match item_type.as_str() {
"folder" => TrashedItemType::Folder, "folder" => TrashedItemType::Folder,
_ => TrashedItemType::File, _ => TrashedItemType::File,
}; };
let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil()); let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil());
// In the soft-delete model, the trash entry ID is the same as the // In the soft-delete model, the trash entry ID is the same as the
// original item ID since there is no separate trash table. // original item ID since there is no separate trash table.
TrashedItem::from_raw( TrashedItem::from_raw(
id, // trash entry id (same as original) id, // trash entry id (same as original)
id, // original item id id, // original item id
user_uuid, // owner user_uuid, // owner
item_type_enum, item_type_enum,
name.clone(), name.clone(),
String::new(), // original_path — not stored separately in soft-delete model String::new(), // original_path — not stored separately in soft-delete model
trashed_at, trashed_at,
deletion_date, deletion_date,
) )
} }
} }
#[async_trait] #[async_trait]
impl TrashRepository for TrashDbRepository { impl TrashRepository for TrashDbRepository {
async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> { async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> {
// No-op: the actual flagging is done by FileWritePort::move_to_trash // No-op: the actual flagging is done by FileWritePort::move_to_trash
// or FolderRepository::move_to_trash. This method exists for interface // or FolderRepository::move_to_trash. This method exists for interface
// compatibility with the TrashService. // compatibility with the TrashService.
Ok(()) Ok(())
} }
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> { async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>( let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#" r#"
SELECT id, name, item_type, user_id, trashed_at SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items FROM storage.trash_items
WHERE user_id = $1 WHERE user_id = $1
ORDER BY trashed_at DESC ORDER BY trashed_at DESC
"#, "#,
) )
.bind(user_id.to_string()) .bind(user_id.to_string())
.fetch_all(self.pool.as_ref()) .fetch_all(self.pool.as_ref())
.await .await
.map_err(|e| { .map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
DomainError::internal_error("TrashDb", format!("list: {e}"))
})?; Ok(rows
.into_iter()
Ok(rows .map(|(id, name, item_type, uid, trashed_at)| {
.into_iter() self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
.map(|(id, name, item_type, uid, trashed_at)| { })
self.row_to_trashed_item(id, name, item_type, uid, trashed_at) .collect())
}) }
.collect())
} async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
let row = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
async fn get_trash_item( r#"
&self, SELECT id, name, item_type, user_id, trashed_at
id: &Uuid, FROM storage.trash_items
user_id: &Uuid, WHERE id = $1 AND user_id = $2
) -> Result<Option<TrashedItem>> { "#,
let row = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>( )
r#" .bind(id)
SELECT id, name, item_type, user_id, trashed_at .bind(user_id.to_string())
FROM storage.trash_items .fetch_optional(self.pool.as_ref())
WHERE id = $1 AND user_id = $2 .await
"#, .map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
)
.bind(id) Ok(row.map(|(id, name, item_type, uid, trashed_at)| {
.bind(user_id.to_string()) self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
.fetch_optional(self.pool.as_ref()) }))
.await }
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("get: {e}")) async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
})?; // No-op: the actual restore is done by FileWritePort::restore_from_trash
// or FolderRepository::restore_from_trash. The TrashService also removes
Ok(row.map(|(id, name, item_type, uid, trashed_at)| { // the index entry — which in the soft-delete model means the flag is
self.row_to_trashed_item(id, name, item_type, uid, trashed_at) // already cleared.
})) Ok(())
} }
async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
// No-op: the actual restore is done by FileWritePort::restore_from_trash // No-op: the actual delete is done by FileWritePort::delete_file_permanently
// or FolderRepository::restore_from_trash. The TrashService also removes // or FolderRepository::delete_folder_permanently.
// the index entry — which in the soft-delete model means the flag is Ok(())
// already cleared. }
Ok(())
} async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
// Delete all trashed files for this user
async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE")
// No-op: the actual delete is done by FileWritePort::delete_file_permanently .bind(user_id.to_string())
// or FolderRepository::delete_folder_permanently. .execute(self.pool.as_ref())
Ok(()) .await
} .map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?;
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> { // Delete all trashed folders for this user
// Delete all trashed files for this user sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE")
sqlx::query( .bind(user_id.to_string())
"DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE", .execute(self.pool.as_ref())
) .await
.bind(user_id.to_string()) .map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?;
.execute(self.pool.as_ref())
.await Ok(())
.map_err(|e| { }
DomainError::internal_error("TrashDb", format!("clear files: {e}"))
})?; async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
// Delete all trashed folders for this user
sqlx::query( let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
"DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE", r#"
) SELECT id, name, item_type, user_id, trashed_at
.bind(user_id.to_string()) FROM storage.trash_items
.execute(self.pool.as_ref()) WHERE trashed_at < $1
.await ORDER BY trashed_at ASC
.map_err(|e| { "#,
DomainError::internal_error("TrashDb", format!("clear folders: {e}")) )
})?; .bind(cutoff)
.fetch_all(self.pool.as_ref())
Ok(()) .await
} .map_err(|e| DomainError::internal_error("TrashDb", format!("expired: {e}")))?;
async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> { Ok(rows
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days); .into_iter()
.map(|(id, name, item_type, uid, trashed_at)| {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>( self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
r#" })
SELECT id, name, item_type, user_id, trashed_at .collect())
FROM storage.trash_items }
WHERE trashed_at < $1 }
ORDER BY trashed_at ASC
"#,
)
.bind(cutoff)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("expired: {e}"))
})?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
})
.collect())
}
}
+2 -7
View File
@@ -209,9 +209,7 @@ impl DedupService {
} }
// Atomic write: temp file → rename // Atomic write: temp file → rename
let temp_path = self let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4()));
.temp_root
.join(format!("{}.tmp", uuid::Uuid::new_v4()));
fs::write(&temp_path, content).await.map_err(|e| { fs::write(&temp_path, content).await.map_err(|e| {
DomainError::internal_error("Dedup", format!("Failed to write temp blob: {}", e)) DomainError::internal_error("Dedup", format!("Failed to write temp blob: {}", e))
})?; })?;
@@ -258,10 +256,7 @@ impl DedupService {
let file_size = fs::metadata(source_path) let file_size = fs::metadata(source_path)
.await .await
.map_err(|e| { .map_err(|e| {
DomainError::internal_error( DomainError::internal_error("Dedup", format!("Failed to get file metadata: {}", e))
"Dedup",
format!("Failed to get file metadata: {}", e),
)
})? })?
.len(); .len();
+20 -20
View File
@@ -171,7 +171,7 @@ async fn handle_propfind(
if path.is_empty() { if path.is_empty() {
// Root CalDAV path — list user's calendars // Root CalDAV path — list user's calendars
let calendars = calendar_service let calendars = calendar_service
.list_my_calendars_for_user(&user.id) .list_my_calendars(&user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?;
@@ -198,13 +198,13 @@ async fn handle_propfind(
if parts.len() == 1 { if parts.len() == 1 {
// Calendar collection // Calendar collection
let calendar = calendar_service let calendar = calendar_service
.get_calendar_for_user(calendar_id, &user.id) .get_calendar(calendar_id, &user.id)
.await .await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
let events = if depth != "0" { let events = if depth != "0" {
calendar_service calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.unwrap_or_default() .unwrap_or_default()
} else { } else {
@@ -235,7 +235,7 @@ async fn handle_propfind(
let ical_uid = event_file.trim_end_matches(".ics"); let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
@@ -295,14 +295,14 @@ async fn handle_report(
CalDavReportType::CalendarQuery { time_range, .. } => { CalDavReportType::CalendarQuery { time_range, .. } => {
if let Some((start, end)) = time_range { if let Some((start, end)) = time_range {
calendar_service calendar_service
.get_events_in_range_for_user(calendar_id, *start, *end, &user.id) .get_events_in_range(calendar_id, *start, *end, &user.id)
.await .await
.map_err(|e| { .map_err(|e| {
AppError::internal_error(format!("Failed to query events: {}", e)) AppError::internal_error(format!("Failed to query events: {}", e))
})? })?
} else { } else {
calendar_service calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| { .map_err(|e| {
AppError::internal_error(format!("Failed to list events: {}", e)) AppError::internal_error(format!("Failed to list events: {}", e))
@@ -311,7 +311,7 @@ async fn handle_report(
} }
CalDavReportType::CalendarMultiget { hrefs, .. } => { CalDavReportType::CalendarMultiget { hrefs, .. } => {
let all_events = calendar_service let all_events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
@@ -321,7 +321,7 @@ async fn handle_report(
.collect() .collect()
} }
CalDavReportType::SyncCollection { .. } => calendar_service CalDavReportType::SyncCollection { .. } => calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?, .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?,
}; };
@@ -377,7 +377,7 @@ async fn handle_mkcalendar(
}; };
calendar_service calendar_service
.create_calendar_for_user(create_dto, &user.id) .create_calendar(create_dto, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to create calendar: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to create calendar: {}", e)))?;
@@ -417,7 +417,7 @@ async fn handle_put(
let existing = if let Some(ref uid) = ical_uid { let existing = if let Some(ref uid) = ical_uid {
let events = calendar_service let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.unwrap_or_default(); .unwrap_or_default();
events.into_iter().find(|e| e.ical_uid == *uid) events.into_iter().find(|e| e.ical_uid == *uid)
@@ -428,7 +428,7 @@ async fn handle_put(
if let Some(existing_event) = existing { if let Some(existing_event) = existing {
// Update existing event — re-create from iCal for full fidelity // Update existing event — re-create from iCal for full fidelity
calendar_service calendar_service
.delete_event_for_user(&existing_event.id, &user.id) .delete_event(&existing_event.id, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to update event: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to update event: {}", e)))?;
@@ -437,7 +437,7 @@ async fn handle_put(
ical_data, ical_data,
}; };
let event = calendar_service let event = calendar_service
.create_event_from_ical_for_user(create_dto, &user.id) .create_event_from_ical(create_dto, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to recreate event: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to recreate event: {}", e)))?;
@@ -453,7 +453,7 @@ async fn handle_put(
}; };
let event = calendar_service let event = calendar_service
.create_event_from_ical_for_user(create_dto, &user.id) .create_event_from_ical(create_dto, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to create event: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to create event: {}", e)))?;
@@ -492,12 +492,12 @@ async fn handle_get(
if parts.len() < 2 { if parts.len() < 2 {
// GET on calendar collection // GET on calendar collection
let events = calendar_service let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
let calendar = calendar_service let calendar = calendar_service
.get_calendar_for_user(calendar_id, &user.id) .get_calendar(calendar_id, &user.id)
.await .await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
@@ -515,7 +515,7 @@ async fn handle_get(
let ical_uid = event_file.trim_end_matches(".ics"); let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
@@ -602,7 +602,7 @@ async fn handle_delete(
if parts.len() < 2 { if parts.len() < 2 {
calendar_service calendar_service
.delete_calendar_for_user(calendar_id, &user.id) .delete_calendar(calendar_id, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to delete calendar: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to delete calendar: {}", e)))?;
} else { } else {
@@ -610,7 +610,7 @@ async fn handle_delete(
let ical_uid = event_file.trim_end_matches(".ics"); let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id) .list_events(calendar_id, None, None, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
@@ -620,7 +620,7 @@ async fn handle_delete(
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
calendar_service calendar_service
.delete_event_for_user(&event.id, &user.id) .delete_event(&event.id, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to delete event: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to delete event: {}", e)))?;
} }
@@ -675,7 +675,7 @@ async fn handle_proppatch(
if update.name.is_some() || update.description.is_some() || update.color.is_some() { if update.name.is_some() || update.description.is_some() || update.color.is_some() {
calendar_service calendar_service
.update_calendar_for_user(calendar_id, update, &user.id) .update_calendar(calendar_id, update, &user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to update calendar: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to update calendar: {}", e)))?;
} }
+141 -93
View File
@@ -1,19 +1,19 @@
use std::sync::Arc;
use std::collections::HashMap;
use axum::{ use axum::{
extract::{Path, State, Query},
http::{StatusCode, header, HeaderName, HeaderValue, Response},
response::IntoResponse,
Json, 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, MoveFolderDto, RenameFolderDto};
use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto};
use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::dtos::pagination::PaginationRequestDto;
use crate::common::errors::ErrorKind;
use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState; 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<FolderService>; type AppState = Arc<FolderService>;
@@ -37,14 +37,16 @@ impl FolderHandler {
let home_folder_name = format!("My Folder - {}", auth_user.username); let home_folder_name = format!("My Folder - {}", auth_user.username);
tracing::info!( tracing::info!(
"create_folder: parent_id is None for user '{}', looking up home folder '{}'", "create_folder: parent_id is None for user '{}', looking up home folder '{}'",
auth_user.username, home_folder_name auth_user.username,
home_folder_name
); );
match service.list_folders(None).await { match service.list_folders(None).await {
Ok(folders) => { Ok(folders) => {
if let Some(home) = folders.iter().find(|f| f.name == home_folder_name) { if let Some(home) = folders.iter().find(|f| f.name == home_folder_name) {
tracing::info!( tracing::info!(
"create_folder: resolved home folder ID '{}' for user '{}'", "create_folder: resolved home folder ID '{}' for user '{}'",
home.id, auth_user.username home.id,
auth_user.username
); );
dto.parent_id = Some(home.id.clone()); dto.parent_id = Some(home.id.clone());
} else { } else {
@@ -55,7 +57,10 @@ impl FolderHandler {
} }
} }
Err(e) => { Err(e) => {
tracing::error!("create_folder: failed to list folders for home resolution: {}", e); tracing::error!(
"create_folder: failed to list folders for home resolution: {}",
e
);
} }
} }
} }
@@ -68,12 +73,12 @@ impl FolderHandler {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, err.to_string()).into_response() (status, err.to_string()).into_response()
} }
} }
} }
/// Gets a folder by ID /// Gets a folder by ID
pub async fn get_folder( pub async fn get_folder(
State(service): State<AppState>, State(service): State<AppState>,
@@ -86,12 +91,12 @@ impl FolderHandler {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, err.to_string()).into_response() (status, err.to_string()).into_response()
} }
} }
} }
/// Lists root folders (no parent ID) /// Lists root folders (no parent ID)
/// Non-admin users only see their own home folder. /// Non-admin users only see their own home folder.
pub async fn list_root_folders( pub async fn list_root_folders(
@@ -145,18 +150,20 @@ impl FolderHandler {
parent_id: Option<&str>, parent_id: Option<&str>,
) -> axum::response::Response { ) -> axum::response::Response {
match service.list_folders(parent_id).await { match service.list_folders(parent_id).await {
Ok(folders) => { Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
(StatusCode::OK, Json(folders)).into_response()
},
Err(err) => { Err(err) => {
let status = match err.kind { let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, Json(serde_json::json!({ (
"error": err.to_string() status,
}))).into_response() Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
} }
} }
} }
@@ -172,35 +179,42 @@ impl FolderHandler {
Ok(folders) => { Ok(folders) => {
// Only filter at root level (parent_id == None) // Only filter at root level (parent_id == None)
let filtered = if parent_id.is_none() { let filtered = if parent_id.is_none() {
folders.into_iter().filter(|f| { folders
// Skip hidden/system folders .into_iter()
if f.name.starts_with('.') { .filter(|f| {
return false; // Skip hidden/system folders
} if f.name.starts_with('.') {
// If it's a user home folder, only show if it belongs to this user return false;
if Self::is_user_home_folder(&f.name) { }
return Self::folder_belongs_to_user(&f.name, &auth_user.username); // If it's a user home folder, only show if it belongs to this user
} if Self::is_user_home_folder(&f.name) {
// Non-home folders are visible to everyone return Self::folder_belongs_to_user(&f.name, &auth_user.username);
true }
}).collect() // Non-home folders are visible to everyone
true
})
.collect()
} else { } else {
folders folders
}; };
(StatusCode::OK, Json(filtered)).into_response() (StatusCode::OK, Json(filtered)).into_response()
}, }
Err(err) => { Err(err) => {
let status = match err.kind { let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, Json(serde_json::json!({ (
"error": err.to_string() status,
}))).into_response() Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
} }
} }
} }
/// Lists folders with pagination support (internal helper) /// Lists folders with pagination support (internal helper)
async fn list_folders_paginated_inner( async fn list_folders_paginated_inner(
service: AppState, service: AppState,
@@ -208,23 +222,25 @@ impl FolderHandler {
parent_id: Option<&str>, parent_id: Option<&str>,
) -> axum::response::Response { ) -> axum::response::Response {
match service.list_folders_paginated(parent_id, &pagination).await { match service.list_folders_paginated(parent_id, &pagination).await {
Ok(paginated_result) => { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
(StatusCode::OK, Json(paginated_result)).into_response()
},
Err(err) => { Err(err) => {
let status = match err.kind { let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
// Return a JSON error response // Return a JSON error response
(status, Json(serde_json::json!({ (
"error": err.to_string() status,
}))).into_response() Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
} }
} }
} }
/// Renames a folder /// Renames a folder
pub async fn rename_folder( pub async fn rename_folder(
State(service): State<AppState>, State(service): State<AppState>,
@@ -239,15 +255,19 @@ impl FolderHandler {
ErrorKind::AlreadyExists => StatusCode::CONFLICT, ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
// Return a proper JSON error response // Return a proper JSON error response
(status, Json(serde_json::json!({ (
"error": err.to_string() status,
}))).into_response() Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
} }
} }
} }
/// Moves a folder to a new parent /// Moves a folder to a new parent
pub async fn move_folder( pub async fn move_folder(
State(service): State<AppState>, State(service): State<AppState>,
@@ -262,12 +282,12 @@ impl FolderHandler {
ErrorKind::AlreadyExists => StatusCode::CONFLICT, ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, err.to_string()).into_response() (status, err.to_string()).into_response()
} }
} }
} }
/// Deletes a folder (with trash support) /// Deletes a folder (with trash support)
pub async fn delete_folder( pub async fn delete_folder(
State(service): State<AppState>, State(service): State<AppState>,
@@ -281,58 +301,68 @@ impl FolderHandler {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, err.to_string()).into_response() (status, err.to_string()).into_response()
} }
} }
} }
/// Deletes a folder with trash functionality /// Deletes a folder with trash functionality
pub async fn delete_folder_with_trash( pub async fn delete_folder_with_trash(
State(state): State<GlobalAppState>, State(state): State<GlobalAppState>,
OptionalAuthUser(auth_user): OptionalAuthUser, OptionalAuthUser(auth_user): OptionalAuthUser,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> 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 // Check if trash service is available
if let Some(trash_service) = &state.trash_service { if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id); tracing::info!("Moving folder to trash: {}", id);
// Try to move to trash first // Try to move to trash first
match trash_service.move_to_trash(&id, "folder", user_id).await { match trash_service.move_to_trash(&id, "folder", user_id).await {
Ok(_) => { Ok(_) => {
tracing::info!("Folder successfully moved to trash: {}", id); tracing::info!("Folder successfully moved to trash: {}", id);
return StatusCode::NO_CONTENT.into_response(); return StatusCode::NO_CONTENT.into_response();
}, }
Err(err) => { 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 // Fall through to regular delete if trash fails
} }
} }
} }
// Fallback to permanent delete if trash is unavailable or failed // Fallback to permanent delete if trash is unavailable or failed
let folder_service = &state.applications.folder_service; let folder_service = &state.applications.folder_service;
match folder_service.delete_folder(&id).await { match folder_service.delete_folder(&id).await {
Ok(_) => { Ok(_) => {
tracing::info!("Folder permanently deleted: {}", id); tracing::info!("Folder permanently deleted: {}", id);
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
}, }
Err(err) => { Err(err) => {
tracing::error!("Error deleting folder: {}", err); tracing::error!("Error deleting folder: {}", err);
let status = match err.kind { let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, Json(serde_json::json!({ (
"error": format!("Error deleting folder: {}", err) status,
}))).into_response() Json(serde_json::json!({
"error": format!("Error deleting folder: {}", err)
})),
)
.into_response()
} }
} }
} }
/// Downloads a folder as a ZIP file /// Downloads a folder as a ZIP file
pub async fn download_folder_zip( pub async fn download_folder_zip(
State(state): State<GlobalAppState>, State(state): State<GlobalAppState>,
@@ -340,67 +370,85 @@ impl FolderHandler {
Query(_params): Query<HashMap<String, String>>, Query(_params): Query<HashMap<String, String>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
tracing::info!("Downloading folder as ZIP: {}", id); tracing::info!("Downloading folder as ZIP: {}", id);
// Get folder information first to check it exists and get name // Get folder information first to check it exists and get name
let folder_service = &state.applications.folder_service; let folder_service = &state.applications.folder_service;
match folder_service.get_folder(&id).await { match folder_service.get_folder(&id).await {
Ok(folder) => { Ok(folder) => {
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
// Use ZIP service from DI container // Use ZIP service from DI container
let zip_service = &state.core.zip_service; let zip_service = &state.core.zip_service;
// Create the ZIP file // Create the ZIP file
match zip_service.create_folder_zip(&id, &folder.name).await { match zip_service.create_folder_zip(&id, &folder.name).await {
Ok(zip_data) => { 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 // Setup headers for download
let filename = format!("{}.zip", folder.name); let filename = format!("{}.zip", folder.name);
let content_disposition = format!("attachment; filename=\"{}\"", filename); let content_disposition = format!("attachment; filename=\"{}\"", filename);
// Build response with the ZIP data // Build response with the ZIP data
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert(header::CONTENT_TYPE.to_string(), "application/zip".to_string()); headers.insert(
headers.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); header::CONTENT_TYPE.to_string(),
headers.insert(header::CONTENT_LENGTH.to_string(), zip_data.len().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 // Build the response
let mut response = Response::builder() let mut response = Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.body(axum::body::Body::from(zip_data)) .body(axum::body::Body::from(zip_data))
.unwrap(); .unwrap();
// Add headers to response // Add headers to response
for (name, value) in headers { for (name, value) in headers {
response.headers_mut().insert( response.headers_mut().insert(
HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(&value).unwrap() HeaderValue::from_str(&value).unwrap(),
); );
} }
response response
}, }
Err(err) => { Err(err) => {
tracing::error!("Error creating ZIP file: {}", err); tracing::error!("Error creating ZIP file: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ (
"error": format!("Error creating ZIP file: {}", err) StatusCode::INTERNAL_SERVER_ERROR,
}))).into_response() Json(serde_json::json!({
"error": format!("Error creating ZIP file: {}", err)
})),
)
.into_response()
} }
} }
}, }
Err(err) => { Err(err) => {
tracing::error!("Folder not found: {}", err); tracing::error!("Folder not found: {}", err);
let status = match err.kind { let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, Json(serde_json::json!({ (
"error": format!("Error finding folder: {}", err) status,
}))).into_response() Json(serde_json::json!({
"error": format!("Error finding folder: {}", err)
})),
)
.into_response()
} }
} }
} }
} }