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:
@@ -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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)))?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user