style: apply cargo fmt to entire codebase

Standardize code formatting across all 173 Rust source files
using rustfmt. No functional changes - purely cosmetic.

This establishes a consistent code style baseline for the
project going forward.
This commit is contained in:
Dionisio
2026-02-14 01:29:34 +01:00
parent 67137a3ef2
commit 4c98c5a657
173 changed files with 23368 additions and 17590 deletions
File diff suppressed because it is too large Load Diff
+166 -94
View File
@@ -1,17 +1,17 @@
use std::sync::Arc;
use axum::{
Router,
routing::{post, get, put},
extract::{State, Json, Query},
http::{StatusCode, HeaderMap, header},
extract::{Json, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Redirect},
routing::{get, post, put},
};
use std::sync::Arc;
use crate::common::di::AppState;
use crate::application::dtos::user_dto::{
LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto,
OidcCallbackQueryDto, OidcProviderInfoDto, OidcExchangeDto,
ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto,
RefreshTokenDto, RegisterDto,
};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
pub fn auth_routes() -> Router<Arc<AppState>> {
@@ -26,14 +26,14 @@ pub fn auth_routes() -> Router<Arc<AppState>> {
.route("/oidc/authorize", get(oidc_authorize))
.route("/oidc/callback", get(oidc_callback))
.route("/oidc/exchange", post(oidc_exchange));
// Routes that DO require authentication - we use route_layer to apply middleware
// The middleware will use the state passed with .with_state() from main.rs
let protected_routes = Router::new()
.route("/me", get(get_current_user))
.route("/change-password", put(change_password))
.route("/logout", post(logout));
// Combine public and protected routes
public_routes.merge(protected_routes)
}
@@ -44,21 +44,26 @@ async fn register(
) -> Result<impl IntoResponse, AppError> {
// Add detailed logging for debugging
tracing::info!("Registration attempt for user: {}", dto.username);
// Verify auth service exists
let auth_service = match state.auth_service.as_ref() {
Some(service) => {
tracing::info!("Auth service found, proceeding with registration");
service
},
}
None => {
tracing::error!("Auth service not configured");
return Err(AppError::internal_error("Authentication service not configured"));
return Err(AppError::internal_error(
"Authentication service not configured",
));
}
};
// Fix #5: Block password registration when OIDC-only mode is active
if auth_service.auth_application_service.password_login_disabled() {
if auth_service
.auth_application_service
.password_login_disabled()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Password registration is disabled. Please use SSO/OIDC to sign in.",
@@ -68,21 +73,26 @@ async fn register(
// Check if public registration has been disabled by the admin
if let Some(admin_svc) = state.admin_settings_service.as_ref()
&& !admin_svc.get_registration_enabled().await {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Public registration has been disabled by the administrator.",
"RegistrationDisabled",
));
}
&& !admin_svc.get_registration_enabled().await
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Public registration has been disabled by the administrator.",
"RegistrationDisabled",
));
}
// Registration logic (admin detection, fresh-install handling, duplicate
// checks) is all inside the service layer. Call it directly.
match auth_service.auth_application_service.register(dto.clone()).await {
match auth_service
.auth_application_service
.register(dto.clone())
.await
{
Ok(user) => {
tracing::info!("Registration successful for user: {}", dto.username);
Ok((StatusCode::CREATED, Json(user)))
},
}
Err(err) => {
tracing::error!("Registration failed for user {}: {}", dto.username, err);
Err(err.into())
@@ -96,41 +106,55 @@ async fn login(
) -> Result<impl IntoResponse, AppError> {
// Add detailed logging for debugging
tracing::info!("Login attempt for user: {}", dto.username);
// Verify auth service exists
// Verify auth service exists
let auth_service = match state.auth_service.as_ref() {
Some(service) => {
tracing::info!("Auth service found, proceeding with login");
service
},
}
None => {
tracing::error!("Auth service not configured");
return Err(AppError::internal_error("Authentication service not configured"));
return Err(AppError::internal_error(
"Authentication service not configured",
));
}
};
// Check if password login is disabled (OIDC-only mode)
if auth_service.auth_application_service.password_login_disabled() {
if auth_service
.auth_application_service
.password_login_disabled()
{
return Err(AppError::unauthorized(
"Password login is disabled. Please use SSO/OIDC to sign in."
"Password login is disabled. Please use SSO/OIDC to sign in.",
));
}
// Try the normal login process
match auth_service.auth_application_service.login(dto.clone()).await {
match auth_service
.auth_application_service
.login(dto.clone())
.await
{
Ok(auth_response) => {
tracing::info!("Login successful for user: {}", dto.username);
// Log the response structure for debugging
tracing::debug!("Auth response: {:?}", &auth_response);
// Ensure the response has the expected fields
if auth_response.access_token.is_empty() || auth_response.refresh_token.is_empty() {
tracing::error!("Login response contains empty tokens for user: {}", dto.username);
return Err(AppError::internal_error("Error generating authentication tokens"));
tracing::error!(
"Login response contains empty tokens for user: {}",
dto.username
);
return Err(AppError::internal_error(
"Error generating authentication tokens",
));
}
Ok((StatusCode::OK, Json(auth_response)))
},
}
Err(err) => {
tracing::error!("Login failed for user {}: {}", dto.username, err);
Err(err.into())
@@ -144,19 +168,24 @@ async fn refresh_token(
) -> Result<impl IntoResponse, AppError> {
// Add rate limiting for token refresh to prevent refresh loops
// Check if this refresh token is being used too frequently
// Log the refresh attempt for debugging
tracing::info!("Token refresh requested");
// Normal process for real tokens
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
let auth_response = auth_service.auth_application_service.refresh_token(dto).await?;
let auth_response = auth_service
.auth_application_service
.refresh_token(dto)
.await?;
// Log successful token refresh
tracing::info!("Token refresh successful, new token issued");
Ok((StatusCode::OK, Json(auth_response)))
}
@@ -165,40 +194,54 @@ async fn get_current_user(
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
// Normal process for all users
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// Extract and validate the token directly
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
// Validate the token and get claims
let claims = auth_service.token_service.validate_token(token)
let claims = auth_service
.token_service
.validate_token(token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
let user_id = claims.sub;
// First, update the storage usage statistics
// IMPORTANT: We await the calculation to return updated data
if let Some(storage_usage_service) = state.storage_usage_service.as_ref() {
// Calculate storage synchronously (we await the result)
match storage_usage_service.update_user_storage_usage(&user_id).await {
match storage_usage_service
.update_user_storage_usage(&user_id)
.await
{
Ok(usage) => {
tracing::info!("Updated storage usage for user {}: {} bytes", user_id, usage);
},
tracing::info!(
"Updated storage usage for user {}: {} bytes",
user_id,
usage
);
}
Err(e) => {
// Only log a warning, don't fail the entire request
tracing::warn!("Failed to update storage usage for user {}: {}", user_id, e);
}
}
}
// Now get the user data WITH the updated storage
let user = auth_service.auth_application_service.get_user_by_id(&user_id).await?;
let user = auth_service
.auth_application_service
.get_user_by_id(&user_id)
.await?;
Ok((StatusCode::OK, Json(user)))
}
@@ -207,22 +250,29 @@ async fn change_password(
headers: HeaderMap,
Json(dto): Json<ChangePasswordDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// Extract and validate the token directly
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
// Validate the token and get claims
let claims = auth_service.token_service.validate_token(token)
let claims = auth_service
.token_service
.validate_token(token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
auth_service.auth_application_service.change_password(&claims.sub, dto).await?;
auth_service
.auth_application_service
.change_password(&claims.sub, dto)
.await?;
Ok(StatusCode::OK)
}
@@ -230,23 +280,30 @@ async fn logout(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// Extract and validate the token directly
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
// Validate the token and get claims
let claims = auth_service.token_service.validate_token(token)
let claims = auth_service
.token_service
.validate_token(token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
// Use access token for logout (we don't have refresh token in headers)
auth_service.auth_application_service.logout(&claims.sub, token).await?;
auth_service
.auth_application_service
.logout(&claims.sub, token)
.await?;
Ok(StatusCode::OK)
}
@@ -265,21 +322,30 @@ struct SystemStatus {
async fn get_system_status(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// Count admin users to determine if system is initialized
let admin_count = auth_service.auth_application_service.count_admin_users().await
let admin_count = auth_service
.auth_application_service
.count_admin_users()
.await
.unwrap_or(0);
let status = SystemStatus {
initialized: admin_count > 0,
admin_count,
registration_allowed: admin_count > 0, // Only allow registration if admin exists
};
tracing::info!("System status check: initialized={}, admin_count={}", status.initialized, status.admin_count);
tracing::info!(
"System status check: initialized={}, admin_count={}",
status.initialized,
status.admin_count
);
Ok((StatusCode::OK, Json(status)))
}
@@ -288,10 +354,10 @@ async fn get_system_status(
// ============================================================================
/// GET /api/auth/oidc/providers — Returns OIDC provider info for the UI
async fn oidc_providers(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
async fn oidc_providers(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
@@ -316,10 +382,10 @@ async fn oidc_providers(
}
/// GET /api/auth/oidc/authorize — Redirects user to the OIDC provider
async fn oidc_authorize(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
async fn oidc_authorize(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
@@ -345,7 +411,9 @@ async fn oidc_callback(
State(state): State<Arc<AppState>>,
Query(query): Query<OidcCallbackQueryDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
@@ -361,7 +429,9 @@ async fn oidc_callback(
tracing::info!("OIDC callback received with code");
// Exchange code, validate state/nonce/PKCE, authenticate user
let exchange_code = auth_app.oidc_callback(&query.code, &query.state).await
let exchange_code = auth_app
.oidc_callback(&query.code, &query.state)
.await
.map_err(|e| {
tracing::error!("OIDC callback failed: {}", e);
AppError::from(e)
@@ -370,11 +440,7 @@ async fn oidc_callback(
// Redirect to frontend with one-time exchange code (NOT raw tokens)
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!(
"{}/?oidc_code={}",
frontend_url,
exchange_code,
);
let redirect_url = format!("{}/?oidc_code={}", frontend_url, exchange_code,);
tracing::info!("OIDC login successful, redirecting with exchange code");
@@ -387,17 +453,23 @@ async fn oidc_exchange(
State(state): State<Arc<AppState>>,
Json(body): Json<OidcExchangeDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_response = auth_service.auth_application_service
let auth_response = auth_service
.auth_application_service
.exchange_oidc_token(&body.code)
.map_err(|e| {
tracing::warn!("OIDC token exchange failed: {}", e);
AppError::from(e)
})?;
tracing::info!("OIDC token exchange successful for user: {}", auth_response.user.username);
tracing::info!(
"OIDC token exchange successful for user: {}",
auth_response.user.username
);
Ok((StatusCode::OK, Json(auth_response)))
}
+84 -63
View File
@@ -1,16 +1,16 @@
use std::sync::Arc;
use axum::{
extract::{State, Json},
response::IntoResponse,
extract::{Json, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::application::services::batch_operations::{
BatchOperationService, BatchResult, BatchStats
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::services::batch_operations::{
BatchOperationService, BatchResult, BatchStats,
};
use crate::interfaces::api::handlers::ApiResult;
/// Shared state for the batch handler
@@ -111,11 +111,13 @@ where
{
fn from(result: BatchResult<T>) -> Self {
let successful = result.successful.into_iter().map(U::from).collect();
let failed = result.failed.into_iter()
let failed = result
.failed
.into_iter()
.map(|(id, error)| FailedOperation { id, error })
.collect();
Self {
successful,
failed,
@@ -135,19 +137,21 @@ pub async fn move_files_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file IDs provided"
}))
).into_response());
})),
)
.into_response());
}
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.move_files(request.file_ids, request.target_folder_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Convert result to DTO
let response: BatchOperationResponse<FileDto> = result.into();
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -158,7 +162,7 @@ pub async fn move_files_batch(
} else {
StatusCode::OK // All successful
};
Ok((status_code, Json(response)).into_response())
}
@@ -173,19 +177,21 @@ pub async fn copy_files_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file IDs provided"
}))
).into_response());
})),
)
.into_response());
}
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.copy_files(request.file_ids, request.target_folder_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Convert result to DTO
let response: BatchOperationResponse<FileDto> = result.into();
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -196,7 +202,7 @@ pub async fn copy_files_batch(
} else {
StatusCode::OK // All successful
};
Ok((status_code, Json(response)).into_response())
}
@@ -211,25 +217,29 @@ pub async fn delete_files_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file IDs provided"
}))
).into_response());
})),
)
.into_response());
}
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.delete_files(request.file_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Create custom response for string IDs
let response = BatchOperationResponse {
successful: result.successful,
failed: result.failed.into_iter()
failed: result
.failed
.into_iter()
.map(|(id, error)| FailedOperation { id, error })
.collect(),
stats: result.stats.into(),
};
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -240,7 +250,7 @@ pub async fn delete_files_batch(
} else {
StatusCode::OK // All successful
};
Ok((status_code, Json(response)).into_response())
}
@@ -255,25 +265,29 @@ pub async fn delete_folders_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No folder IDs provided"
}))
).into_response());
})),
)
.into_response());
}
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.delete_folders(request.folder_ids, request.recursive)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Create custom response for string IDs
let response = BatchOperationResponse {
successful: result.successful,
failed: result.failed.into_iter()
failed: result
.failed
.into_iter()
.map(|(id, error)| FailedOperation { id, error })
.collect(),
stats: result.stats.into(),
};
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -284,7 +298,7 @@ pub async fn delete_folders_batch(
} else {
StatusCode::OK // All successful
};
Ok((status_code, Json(response)).into_response())
}
@@ -299,25 +313,28 @@ pub async fn create_folders_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No folders provided"
}))
).into_response());
})),
)
.into_response());
}
// Transform the format for the service
let folders = request.folders
let folders = request
.folders
.into_iter()
.map(|detail| (detail.name, detail.parent_id))
.collect();
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.create_folders(folders)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Convert result to DTO
let response: BatchOperationResponse<FolderDto> = result.into();
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -328,7 +345,7 @@ pub async fn create_folders_batch(
} else {
StatusCode::CREATED // All successful
};
Ok((status_code, Json(response)).into_response())
}
@@ -343,19 +360,21 @@ pub async fn get_files_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file IDs provided"
}))
).into_response());
})),
)
.into_response());
}
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.get_multiple_files(request.file_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Convert result to DTO
let response: BatchOperationResponse<FileDto> = result.into();
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -366,7 +385,7 @@ pub async fn get_files_batch(
} else {
StatusCode::OK // All successful
};
Ok((status_code, Json(response)).into_response())
}
@@ -381,19 +400,21 @@ pub async fn get_folders_batch(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No folder IDs provided"
}))
).into_response());
})),
)
.into_response());
}
// Execute batch operation
let result = state.batch_service
let result = state
.batch_service
.get_multiple_folders(request.folder_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Convert result to DTO
let response: BatchOperationResponse<FolderDto> = result.into();
// Determine status code based on results
let status_code = if response.stats.failed > 0 {
if response.stats.successful > 0 {
@@ -404,6 +425,6 @@ pub async fn get_folders_batch(
} else {
StatusCode::OK // All successful
};
Ok((status_code, Json(response)).into_response())
}
}
+201 -125
View File
@@ -1,10 +1,10 @@
/**
* CalDAV Handler Module
*
*
* This module implements the CalDAV protocol (RFC 4791) endpoints for OxiCloud.
* It provides calendar access and management through standard CalDAV methods,
* allowing clients like Thunderbird, Apple Calendar, and GNOME Calendar to sync.
*
*
* Supported methods:
* - OPTIONS: Advertise CalDAV capabilities
* - PROPFIND: List calendars and their properties
@@ -15,30 +15,29 @@
* - DELETE: Remove calendars or events
* - PROPPATCH: Modify calendar properties
*/
use axum::{
Router,
body::{self, Body},
http::{HeaderName, Request, StatusCode, header},
response::Response,
http::{StatusCode, header, HeaderName, Request},
body::{Body, self},
};
use std::sync::Arc;
use bytes::Buf;
use std::sync::Arc;
use crate::common::di::AppState;
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::ports::calendar_ports::CalendarUseCase;
use crate::application::dtos::calendar_dto::{
CreateCalendarDto, UpdateCalendarDto, CreateEventICalDto,
CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
};
use crate::interfaces::middleware::auth::CurrentUser;
use crate::application::ports::calendar_ports::CalendarUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Creates CalDAV routes with full path prefixes.
///
///
/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
/// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly.
pub fn caldav_routes() -> Router<AppState> {
@@ -71,7 +70,7 @@ async fn handle_caldav_methods_inner(
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
let state = Arc::new(state);
match method.as_str() {
"OPTIONS" => handle_options().await,
"PROPFIND" => handle_propfind(state, req, &path).await,
@@ -81,7 +80,10 @@ async fn handle_caldav_methods_inner(
"GET" => handle_get(state, req, &path).await,
"DELETE" => handle_delete(state, req, &path).await,
"PROPPATCH" => handle_proppatch(state, req, &path).await,
_ => Err(AppError::method_not_allowed(format!("Method not allowed: {}", method))),
_ => Err(AppError::method_not_allowed(format!(
"Method not allowed: {}",
method
))),
}
}
@@ -93,7 +95,10 @@ fn extract_caldav_path(uri_path: &str) -> String {
} else if uri_path.ends_with("/caldav") {
String::new()
} else {
uri_path.trim_start_matches('/').trim_end_matches('/').to_string()
uri_path
.trim_start_matches('/')
.trim_end_matches('/')
.to_string()
}
}
@@ -122,7 +127,10 @@ async fn handle_options() -> Result<Response<Body>, AppError> {
Ok(Response::builder()
.status(StatusCode::OK)
.header(HEADER_DAV, "1, 2, calendar-access")
.header(header::ALLOW, "OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCALENDAR")
.header(
header::ALLOW,
"OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCALENDAR",
)
.body(Body::empty())
.unwrap())
}
@@ -134,32 +142,39 @@ async fn handle_propfind(
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let depth = req.headers()
let depth = req
.headers()
.get("Depth")
.and_then(|v| v.to_str().ok())
.unwrap_or("1")
.to_string();
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
// Parse PROPFIND request
let propfind_request = if body_bytes.is_empty() {
PropFindRequest { prop_find_type: PropFindType::AllProp }
PropFindRequest {
prop_find_type: PropFindType::AllProp,
}
} else {
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_propfind(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_propfind(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
};
if path.is_empty() {
// Root CalDAV path — list user's calendars
let calendars = calendar_service.list_my_calendars_for_user(&user.id).await
let calendars = calendar_service
.list_my_calendars_for_user(&user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?;
let base_href = "/caldav/";
let mut response_body = Vec::new();
CalDavAdapter::generate_calendars_propfind_response(
@@ -167,8 +182,9 @@ async fn handle_propfind(
&calendars,
&propfind_request,
base_href,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -178,22 +194,26 @@ async fn handle_propfind(
// Path could be: {calendar_id} or {calendar_id}/{event_uid}.ics
let parts: Vec<&str> = path.splitn(2, '/').collect();
let calendar_id = parts[0];
if parts.len() == 1 {
// Calendar collection
let calendar = calendar_service.get_calendar_for_user(calendar_id, &user.id).await
let calendar = calendar_service
.get_calendar_for_user(calendar_id, &user.id)
.await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
let events = if depth != "0" {
calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
let base_href = &format!("/caldav/{}/", calendar_id);
let mut response_body = Vec::new();
CalDavAdapter::generate_calendar_collection_propfind(
&mut response_body,
&calendar,
@@ -201,8 +221,9 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -212,27 +233,32 @@ async fn handle_propfind(
// Individual event .ics
let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
let event = events.iter().find(|e| e.ical_uid == ical_uid)
let event = events
.iter()
.find(|e| e.ical_uid == ical_uid)
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
let base_href = &format!("/caldav/{}/", calendar_id);
let report_type = CalDavReportType::CalendarMultiget {
hrefs: vec![format!("{}{}.ics", base_href, ical_uid)],
props: vec![],
};
let mut response_body = Vec::new();
CalDavAdapter::generate_calendar_events_response(
&mut response_body,
&[event.clone()],
&report_type,
base_href,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -251,44 +277,55 @@ async fn handle_report(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let report = CalDavAdapter::parse_report(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?;
let calendar_id = path.split('/').next().unwrap_or(path);
if calendar_id.is_empty() {
return Err(AppError::bad_request("Calendar ID required in path"));
}
let events = match &report {
CalDavReportType::CalendarQuery { time_range, .. } => {
if let Some((start, end)) = time_range {
calendar_service.get_events_in_range_for_user(calendar_id, *start, *end, &user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to query events: {}", e)))?
calendar_service
.get_events_in_range_for_user(calendar_id, *start, *end, &user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to query events: {}", e))
})?
} else {
calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?
calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list events: {}", e))
})?
}
},
}
CalDavReportType::CalendarMultiget { hrefs, .. } => {
let all_events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
let all_events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
all_events.into_iter()
all_events
.into_iter()
.filter(|evt| hrefs.iter().any(|href| href.contains(&evt.ical_uid)))
.collect()
},
CalDavReportType::SyncCollection { .. } => {
calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?
},
}
CalDavReportType::SyncCollection { .. } => calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?,
};
let base_href = &format!("/caldav/{}/", calendar_id);
let mut response_body = Vec::new();
CalDavAdapter::generate_calendar_events_response(
@@ -296,8 +333,9 @@ async fn handle_report(
&events,
&report,
base_href,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -314,29 +352,35 @@ async fn handle_mkcalendar(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (name, description, color) = if body_bytes.is_empty() {
let name = path.split('/').next_back().unwrap_or("New Calendar").to_string();
let name = path
.split('/')
.next_back()
.unwrap_or("New Calendar")
.to_string();
(name, None, None)
} else {
CalDavAdapter::parse_mkcalendar(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse MKCALENDAR: {}", e)))?
};
let create_dto = CreateCalendarDto {
name,
description,
color,
is_public: Some(false),
};
calendar_service.create_calendar_for_user(create_dto, &user.id).await
calendar_service
.create_calendar_for_user(create_dto, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create calendar: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
@@ -352,43 +396,51 @@ async fn handle_put(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(AppError::bad_request("Path must be {calendar_id}/{uid}.ics"));
return Err(AppError::bad_request(
"Path must be {calendar_id}/{uid}.ics",
));
}
let calendar_id = parts[0];
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let ical_data = String::from_utf8(body_bytes.to_vec())
.map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in iCalendar data: {}", e)))?;
let ical_uid = extract_uid_from_ical(&ical_data);
let existing = if let Some(ref uid) = ical_uid {
let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.unwrap_or_default();
events.into_iter().find(|e| e.ical_uid == *uid)
} else {
None
};
if let Some(existing_event) = existing {
// Update existing event — re-create from iCal for full fidelity
calendar_service.delete_event_for_user(&existing_event.id, &user.id).await
calendar_service
.delete_event_for_user(&existing_event.id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update event: {}", e)))?;
let create_dto = CreateEventICalDto {
calendar_id: calendar_id.to_string(),
ical_data,
};
let event = calendar_service.create_event_from_ical_for_user(create_dto, &user.id).await
let event = calendar_service
.create_event_from_ical_for_user(create_dto, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to recreate event: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", event.id))
@@ -399,10 +451,12 @@ async fn handle_put(
calendar_id: calendar_id.to_string(),
ical_data,
};
let event = calendar_service.create_event_from_ical_for_user(create_dto, &user.id).await
let event = calendar_service
.create_event_from_ical_for_user(create_dto, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create event: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", event.id))
@@ -431,20 +485,24 @@ async fn handle_get(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let calendar_id = parts[0];
if parts.len() < 2 {
// GET on calendar collection
let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
let calendar = calendar_service.get_calendar_for_user(calendar_id, &user.id).await
let calendar = calendar_service
.get_calendar_for_user(calendar_id, &user.id)
.await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
let ical = generate_full_calendar_ical(&calendar.name, &events);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
@@ -455,15 +513,19 @@ async fn handle_get(
// GET on individual event
let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
let event = events.iter().find(|e| e.ical_uid == ical_uid)
let event = events
.iter()
.find(|e| e.ical_uid == ical_uid)
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
let ical = generate_event_ical(event);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
@@ -530,31 +592,39 @@ async fn handle_delete(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let calendar_id = parts[0];
if calendar_id.is_empty() {
return Err(AppError::bad_request("Calendar ID required"));
}
if parts.len() < 2 {
calendar_service.delete_calendar_for_user(calendar_id, &user.id).await
calendar_service
.delete_calendar_for_user(calendar_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete calendar: {}", e)))?;
} else {
let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await
let events = calendar_service
.list_events_for_user(calendar_id, None, None, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
let event = events.iter().find(|e| e.ical_uid == ical_uid)
let event = events
.iter()
.find(|e| e.ical_uid == ical_uid)
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
calendar_service.delete_event_for_user(&event.id, &user.id).await
calendar_service
.delete_event_for_user(&event.id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete event: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
@@ -570,27 +640,30 @@ async fn handle_proppatch(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (props_to_set, props_to_remove) = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(body_bytes.reader())
let (props_to_set, props_to_remove) =
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let calendar_id = path.split('/').next().unwrap_or(path);
if calendar_id.is_empty() {
return Err(AppError::bad_request("Calendar ID required"));
}
let mut update = UpdateCalendarDto {
name: None,
description: None,
color: None,
is_public: None,
};
for prop in &props_to_set {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
@@ -599,12 +672,14 @@ async fn handle_proppatch(
_ => {}
}
}
if update.name.is_some() || update.description.is_some() || update.color.is_some() {
calendar_service.update_calendar_for_user(calendar_id, update, &user.id).await
calendar_service
.update_calendar_for_user(calendar_id, update, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update calendar: {}", e)))?;
}
let mut results = Vec::new();
for prop in &props_to_set {
results.push((&prop.name, true));
@@ -612,18 +687,19 @@ async fn handle_proppatch(
for prop in &props_to_remove {
results.push((prop, true));
}
let href = format!("/caldav/{}", path);
let mut response_body = Vec::new();
crate::application::adapters::webdav_adapter::WebDavAdapter::generate_proppatch_response(
&mut response_body,
&href,
&results,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap())
}
}
+209 -131
View File
@@ -1,11 +1,11 @@
/**
* CardDAV Handler Module
*
*
* This module implements the CardDAV protocol (RFC 6352) endpoints for OxiCloud.
* It provides contact/address book access and management through standard CardDAV
* methods, allowing clients like Thunderbird, Apple Contacts, GNOME Contacts,
* and DAVx⁵ to sync contacts.
*
*
* Supported methods:
* - OPTIONS: Advertise CardDAV capabilities
* - PROPFIND: List address books and their properties
@@ -16,36 +16,38 @@
* - DELETE: Remove address books or contacts
* - PROPPATCH: Modify address book properties
*/
use axum::{
Router,
body::{self, Body},
http::{HeaderName, Request, StatusCode, header},
response::Response,
http::{StatusCode, header, HeaderName, Request},
body::{Body, self},
};
use std::sync::Arc;
use bytes::Buf;
use std::sync::Arc;
use crate::common::di::AppState;
use crate::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType, contact_to_vcard};
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::dtos::address_book_dto::{
CreateAddressBookDto, UpdateAddressBookDto,
use crate::application::adapters::carddav_adapter::{
CardDavAdapter, CardDavReportType, contact_to_vcard,
};
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
use crate::application::dtos::contact_dto::CreateContactVCardDto;
use crate::interfaces::middleware::auth::CurrentUser;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Creates CardDAV routes with full path prefixes.
///
///
/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
/// Registers `/carddav`, `/carddav/`, and `/carddav/{*path}` explicitly.
pub fn carddav_routes() -> Router<AppState> {
Router::new()
.route("/carddav/{*path}", axum::routing::any(handle_carddav_methods))
.route(
"/carddav/{*path}",
axum::routing::any(handle_carddav_methods),
)
.route("/carddav/", axum::routing::any(handle_carddav_methods_root))
.route("/carddav", axum::routing::any(handle_carddav_methods_root))
}
@@ -73,7 +75,7 @@ async fn handle_carddav_methods_inner(
) -> Result<Response<Body>, AppError> {
let state = Arc::new(state);
let method = req.method().clone();
match method.as_str() {
"OPTIONS" => handle_options().await,
"PROPFIND" => handle_propfind(state.clone(), req, &path).await,
@@ -83,7 +85,10 @@ async fn handle_carddav_methods_inner(
"GET" => handle_get(state.clone(), req, &path).await,
"DELETE" => handle_delete(state.clone(), req, &path).await,
"PROPPATCH" => handle_proppatch(state.clone(), req, &path).await,
_ => Err(AppError::method_not_allowed(format!("Method not allowed: {}", method))),
_ => Err(AppError::method_not_allowed(format!(
"Method not allowed: {}",
method
))),
}
}
@@ -95,7 +100,10 @@ fn extract_carddav_path(uri_path: &str) -> String {
} else if uri_path.ends_with("/carddav") {
String::new()
} else {
uri_path.trim_start_matches('/').trim_end_matches('/').to_string()
uri_path
.trim_start_matches('/')
.trim_end_matches('/')
.to_string()
}
}
@@ -134,7 +142,10 @@ async fn handle_options() -> Result<Response<Body>, AppError> {
Ok(Response::builder()
.status(StatusCode::OK)
.header(HEADER_DAV, "1, 2, 3, addressbook")
.header(header::ALLOW, "OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL")
.header(
header::ALLOW,
"OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL",
)
.body(Body::empty())
.unwrap())
}
@@ -146,32 +157,41 @@ async fn handle_propfind(
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let depth = req.headers()
let depth = req
.headers()
.get("Depth")
.and_then(|v| v.to_str().ok())
.unwrap_or("1")
.to_string();
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let contact_svc = get_contact_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let propfind_request = if body_bytes.is_empty() {
PropFindRequest { prop_find_type: PropFindType::AllProp }
PropFindRequest {
prop_find_type: PropFindType::AllProp,
}
} else {
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_propfind(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_propfind(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
};
if path.is_empty() {
// Root CardDAV path — list user's address books
let address_books = addressbook_service.list_user_address_books(&user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to list address books: {}", e)))?;
let address_books = addressbook_service
.list_user_address_books(&user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?;
let base_href = "/carddav/";
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbooks_propfind_response(
@@ -179,8 +199,9 @@ async fn handle_propfind(
&address_books,
&propfind_request,
base_href,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -189,22 +210,26 @@ async fn handle_propfind(
} else {
let parts: Vec<&str> = path.splitn(2, '/').collect();
let address_book_id = parts[0];
if parts.len() == 1 {
// Address book collection
let address_book = addressbook_service.get_address_book(address_book_id, &user.id).await
let address_book = addressbook_service
.get_address_book(address_book_id, &user.id)
.await
.map_err(|e| AppError::not_found(format!("Address book not found: {}", e)))?;
let contacts = if depth != "0" {
contact_svc.list_contacts(address_book_id, &user.id).await
contact_svc
.list_contacts(address_book_id, &user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
let base_href = &format!("/carddav/{}/", address_book_id);
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbook_collection_propfind(
&mut response_body,
&address_book,
@@ -212,8 +237,9 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -223,21 +249,27 @@ async fn handle_propfind(
// Individual contact .vcf
let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf");
// Look up by UID across all contacts in this address book
let contacts = contact_svc.list_contacts(address_book_id, &user.id).await
let contacts = contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
let contact = contacts.iter().find(|c| c.uid == contact_uid)
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
let contact = contacts
.iter()
.find(|c| c.uid == contact_uid)
.ok_or_else(|| {
AppError::not_found(format!("Contact not found: {}", contact_uid))
})?;
// Build single-resource PROPFIND response
let base_href = &format!("/carddav/{}/", address_book_id);
let report = CardDavReportType::AddressbookMultiget {
hrefs: vec![format!("{}{}.vcf", base_href, contact_uid)],
props: vec![],
};
let mut response_body = Vec::new();
CardDavAdapter::generate_contacts_response(
&mut response_body,
@@ -245,8 +277,9 @@ async fn handle_propfind(
&[(contact.uid.clone(), contact_to_vcard(contact))],
&report,
base_href,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -265,44 +298,48 @@ async fn handle_report(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let report = CardDavAdapter::parse_report(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?;
let address_book_id = path.split('/').next().unwrap_or(path);
if address_book_id.is_empty() {
return Err(AppError::bad_request("Address book ID required in path"));
}
let contacts = match &report {
CardDavReportType::AddressbookQuery { .. } => {
contact_svc.list_contacts(address_book_id, &user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?
},
CardDavReportType::AddressbookQuery { .. } => contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?,
CardDavReportType::AddressbookMultiget { hrefs, .. } => {
let all_contacts = contact_svc.list_contacts(address_book_id, &user.id).await
let all_contacts = contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
all_contacts.into_iter()
all_contacts
.into_iter()
.filter(|c| hrefs.iter().any(|href| href.contains(&c.uid)))
.collect()
},
CardDavReportType::SyncCollection { .. } => {
contact_svc.list_contacts(address_book_id, &user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?
},
}
CardDavReportType::SyncCollection { .. } => contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?,
};
// Generate vCards
let vcards: Vec<(String, String)> = contacts.iter()
let vcards: Vec<(String, String)> = contacts
.iter()
.map(|c| (c.uid.clone(), contact_to_vcard(c)))
.collect();
let base_href = &format!("/carddav/{}/", address_book_id);
let mut response_body = Vec::new();
CardDavAdapter::generate_contacts_response(
@@ -311,8 +348,9 @@ async fn handle_report(
&vcards,
&report,
base_href,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -329,19 +367,23 @@ async fn handle_mkcol(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (name, description, color) = if body_bytes.is_empty() {
let name = path.split('/').next_back().unwrap_or("New Address Book").to_string();
let name = path
.split('/')
.next_back()
.unwrap_or("New Address Book")
.to_string();
(name, None, None)
} else {
CardDavAdapter::parse_mkaddressbook(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse MKCOL: {}", e)))?
};
let create_dto = CreateAddressBookDto {
name,
owner_id: user.id.clone(),
@@ -349,10 +391,12 @@ async fn handle_mkcol(
color,
is_public: Some(false),
};
addressbook_service.create_address_book(create_dto).await
addressbook_service
.create_address_book(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create address book: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
@@ -368,46 +412,54 @@ async fn handle_put(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(AppError::bad_request("Path must be {address_book_id}/{uid}.vcf"));
return Err(AppError::bad_request(
"Path must be {address_book_id}/{uid}.vcf",
));
}
let address_book_id = parts[0];
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let vcard_data = String::from_utf8(body_bytes.to_vec())
.map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in vCard data: {}", e)))?;
// Extract UID from vCard
let vcard_uid = extract_uid_from_vcard(&vcard_data);
// Check if contact already exists
let existing = if let Some(ref uid) = vcard_uid {
let contacts = contact_svc.list_contacts(address_book_id, &user.id).await
let contacts = contact_svc
.list_contacts(address_book_id, &user.id)
.await
.unwrap_or_default();
contacts.into_iter().find(|c| c.uid == *uid)
} else {
None
};
if let Some(existing_contact) = existing {
// Update: delete + recreate from vCard
contact_svc.delete_contact(&existing_contact.id, &user.id).await
contact_svc
.delete_contact(&existing_contact.id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update contact: {}", e)))?;
let create_dto = CreateContactVCardDto {
address_book_id: address_book_id.to_string(),
vcard: vcard_data,
user_id: user.id.clone(),
};
let contact = contact_svc.create_contact_from_vcard(create_dto).await
let contact = contact_svc
.create_contact_from_vcard(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to recreate contact: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", contact.etag))
@@ -419,10 +471,12 @@ async fn handle_put(
vcard: vcard_data,
user_id: user.id.clone(),
};
let contact = contact_svc.create_contact_from_vcard(create_dto).await
let contact = contact_svc
.create_contact_from_vcard(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create contact: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", contact.etag))
@@ -451,20 +505,22 @@ async fn handle_get(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let address_book_id = parts[0];
if parts.len() < 2 {
// GET on address book collection — return all contacts as vcf
let contacts = contact_svc.list_contacts(address_book_id, &user.id).await
let contacts = contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
let mut vcf_data = String::new();
for contact in &contacts {
vcf_data.push_str(&contact_to_vcard(contact));
}
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/vcard; charset=utf-8")
@@ -474,15 +530,19 @@ async fn handle_get(
// GET on individual contact
let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf");
let contacts = contact_svc.list_contacts(address_book_id, &user.id).await
let contacts = contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
let contact = contacts.iter().find(|c| c.uid == contact_uid)
let contact = contacts
.iter()
.find(|c| c.uid == contact_uid)
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
let vcard = contact_to_vcard(contact);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/vcard; charset=utf-8")
@@ -502,33 +562,43 @@ async fn handle_delete(
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let contact_svc = get_contact_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let address_book_id = parts[0];
if address_book_id.is_empty() {
return Err(AppError::bad_request("Address book ID required"));
}
if parts.len() < 2 {
// Delete address book
addressbook_service.delete_address_book(address_book_id, &user.id).await
.map_err(|e| AppError::internal_error(format!("Failed to delete address book: {}", e)))?;
addressbook_service
.delete_address_book(address_book_id, &user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete address book: {}", e))
})?;
} else {
// Delete contact
let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf");
let contacts = contact_svc.list_contacts(address_book_id, &user.id).await
let contacts = contact_svc
.list_contacts(address_book_id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
let contact = contacts.iter().find(|c| c.uid == contact_uid)
let contact = contacts
.iter()
.find(|c| c.uid == contact_uid)
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
contact_svc.delete_contact(&contact.id, &user.id).await
contact_svc
.delete_contact(&contact.id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete contact: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
@@ -544,20 +614,23 @@ async fn handle_proppatch(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (props_to_set, props_to_remove) = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(body_bytes.reader())
let (props_to_set, props_to_remove) =
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let address_book_id = path.split('/').next().unwrap_or(path);
if address_book_id.is_empty() {
return Err(AppError::bad_request("Address book ID required"));
}
let mut update = UpdateAddressBookDto {
name: None,
description: None,
@@ -565,7 +638,7 @@ async fn handle_proppatch(
is_public: None,
user_id: user.id.clone(),
};
for prop in &props_to_set {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
@@ -574,12 +647,16 @@ async fn handle_proppatch(
_ => {}
}
}
if update.name.is_some() || update.description.is_some() || update.color.is_some() {
addressbook_service.update_address_book(address_book_id, update).await
.map_err(|e| AppError::internal_error(format!("Failed to update address book: {}", e)))?;
addressbook_service
.update_address_book(address_book_id, update)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to update address book: {}", e))
})?;
}
let mut results = Vec::new();
for prop in &props_to_set {
results.push((&prop.name, true));
@@ -587,15 +664,16 @@ async fn handle_proppatch(
for prop in &props_to_remove {
results.push((prop, true));
}
let href = format!("/carddav/{}", path);
let mut response_body = Vec::new();
crate::application::adapters::webdav_adapter::WebDavAdapter::generate_proppatch_response(
&mut response_body,
&href,
&results,
).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
@@ -1,303 +1,352 @@
//! Chunked Upload Handler - TUS-like Protocol Endpoints
//!
//! Provides HTTP endpoints for resumable, parallel chunk uploads:
//! - POST /api/uploads → Create upload session
//! - PATCH /api/uploads/:id → Upload a chunk
//! - HEAD /api/uploads/:id → Get upload status
//! - POST /api/uploads/:id/complete → Assemble and finalize
//! - DELETE /api/uploads/:id → Cancel upload
use axum::{
extract::{Path, State, Query},
http::{StatusCode, header, HeaderMap},
response::{IntoResponse, Response},
Json,
};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::common::di::AppState;
use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE;
use crate::domain::errors::ErrorKind;
/// Request body for creating an upload session
#[derive(Debug, Deserialize)]
pub struct CreateUploadRequest {
pub filename: String,
pub folder_id: Option<String>,
pub content_type: Option<String>,
pub total_size: u64,
pub chunk_size: Option<usize>,
}
/// Query params for chunk upload
#[derive(Debug, Deserialize)]
pub struct ChunkUploadParams {
pub chunk_index: usize,
pub checksum: Option<String>,
}
/// Final response after completing upload
#[derive(Debug, Serialize)]
pub struct CompleteUploadResponse {
pub file_id: String,
pub filename: String,
pub size: u64,
pub path: String,
}
/// Chunked Upload Handler
pub struct ChunkedUploadHandler;
impl ChunkedUploadHandler {
/// POST /api/uploads - Create a new upload session
///
/// Request body:
/// ```json
/// {
/// "filename": "large-video.mp4",
/// "folder_id": "optional-folder-id",
/// "content_type": "video/mp4",
/// "total_size": 104857600,
/// "chunk_size": 5242880
/// }
/// ```
///
/// Response:
/// ```json
/// {
/// "upload_id": "uuid",
/// "chunk_size": 5242880,
/// "total_chunks": 20,
/// "expires_at": 86400
/// }
/// ```
pub async fn create_upload(
State(state): State<Arc<AppState>>,
Json(request): Json<CreateUploadRequest>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
// Validate request
if request.filename.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Filename is required"
}))).into_response();
}
if request.total_size == 0 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Total size must be greater than 0"
}))).into_response();
}
// Validate chunk size if provided
let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
if chunk_size < 1024 * 1024 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Chunk size must be at least 1MB"
}))).into_response();
}
let content_type = request.content_type
.unwrap_or_else(|| "application/octet-stream".to_string());
match chunked_service.create_session(
request.filename,
request.folder_id,
content_type,
request.total_size,
Some(chunk_size),
).await {
Ok(response) => {
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => {
tracing::error!("Failed to create upload session: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": e.to_string()
}))).into_response()
}
}
}
/// PATCH /api/uploads/:upload_id - Upload a chunk
///
/// Query params:
/// - chunk_index: The index of the chunk (0-based)
/// - checksum: Optional MD5 checksum for verification
///
/// Body: Raw bytes of the chunk
pub async fn upload_chunk(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
Query(params): Query<ChunkUploadParams>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
// Extract checksum from header or query param
let checksum = params.checksum.or_else(|| {
headers.get("Content-MD5")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
match chunked_service.upload_chunk(
&upload_id,
params.chunk_index,
body,
checksum,
).await {
Ok(response) => {
let mut resp = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header("Upload-Offset", response.bytes_received.to_string())
.header("Upload-Progress", format!("{:.2}", response.progress * 100.0));
if response.is_complete {
resp = resp.header("Upload-Complete", "true");
}
resp.body(axum::body::Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
Err(e) => {
let status = match e.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(serde_json::json!({
"error": e.to_string()
}))).into_response()
}
}
}
/// HEAD /api/uploads/:upload_id - Get upload status
///
/// Returns upload progress and pending chunks
pub async fn get_upload_status(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
match chunked_service.get_status(&upload_id).await {
Ok(status) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header("Upload-Offset", status.bytes_received.to_string())
.header("Upload-Length", status.total_size.to_string())
.header("Upload-Progress", format!("{:.2}", status.progress * 100.0))
.header("Upload-Chunks-Total", status.total_chunks.to_string())
.header("Upload-Chunks-Complete", status.completed_chunks.to_string())
.body(axum::body::Body::from(serde_json::to_string(&status).unwrap()))
.unwrap()
.into_response()
}
Err(e) => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": e.to_string()
}))).into_response()
}
}
}
/// POST /api/uploads/:upload_id/complete - Finalize upload
///
/// Assembles all chunks into the final file and creates the file record
pub async fn complete_upload(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
let upload_service = &state.applications.file_upload_service;
// Assemble chunks
let (assembled_path, filename, folder_id, content_type, total_size) =
match chunked_service.complete_upload(&upload_id).await {
Ok(result) => result,
Err(e) => {
let status = match e.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
ErrorKind::InvalidInput | ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
return (status, Json(serde_json::json!({
"error": e.to_string()
}))).into_response();
}
};
// Read assembled file and create final file record
let file_data = match tokio::fs::read(&assembled_path).await {
Ok(data) => data,
Err(e) => {
tracing::error!("Failed to read assembled file: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Failed to read assembled file: {}", e)
}))).into_response();
}
};
// Upload via normal service (this handles path resolution, metadata, etc.)
match upload_service.upload_file(
filename.clone(),
folder_id.clone(),
content_type,
file_data,
).await {
Ok(file) => {
// Cleanup session
let _ = chunked_service.finalize_upload(&upload_id).await;
tracing::info!(
"✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)",
filename, file.id, total_size
);
(StatusCode::CREATED, Json(CompleteUploadResponse {
file_id: file.id,
filename: file.name,
size: total_size,
path: file.path,
})).into_response()
}
Err(e) => {
tracing::error!("Failed to create file from assembled upload: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Failed to create file: {:?}", e)
}))).into_response()
}
}
}
/// DELETE /api/uploads/:upload_id - Cancel upload
///
/// Cancels an in-progress upload and cleans up temp files
pub async fn cancel_upload(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
match chunked_service.cancel_upload(&upload_id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": e.to_string()
}))).into_response()
}
}
}
}
//! Chunked Upload Handler - TUS-like Protocol Endpoints
//!
//! Provides HTTP endpoints for resumable, parallel chunk uploads:
//! - POST /api/uploads → Create upload session
//! - PATCH /api/uploads/:id → Upload a chunk
//! - HEAD /api/uploads/:id → Get upload status
//! - POST /api/uploads/:id/complete → Assemble and finalize
//! - DELETE /api/uploads/:id → Cancel upload
use axum::{
Json,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE;
use crate::common::di::AppState;
use crate::domain::errors::ErrorKind;
/// Request body for creating an upload session
#[derive(Debug, Deserialize)]
pub struct CreateUploadRequest {
pub filename: String,
pub folder_id: Option<String>,
pub content_type: Option<String>,
pub total_size: u64,
pub chunk_size: Option<usize>,
}
/// Query params for chunk upload
#[derive(Debug, Deserialize)]
pub struct ChunkUploadParams {
pub chunk_index: usize,
pub checksum: Option<String>,
}
/// Final response after completing upload
#[derive(Debug, Serialize)]
pub struct CompleteUploadResponse {
pub file_id: String,
pub filename: String,
pub size: u64,
pub path: String,
}
/// Chunked Upload Handler
pub struct ChunkedUploadHandler;
impl ChunkedUploadHandler {
/// POST /api/uploads - Create a new upload session
///
/// Request body:
/// ```json
/// {
/// "filename": "large-video.mp4",
/// "folder_id": "optional-folder-id",
/// "content_type": "video/mp4",
/// "total_size": 104857600,
/// "chunk_size": 5242880
/// }
/// ```
///
/// Response:
/// ```json
/// {
/// "upload_id": "uuid",
/// "chunk_size": 5242880,
/// "total_chunks": 20,
/// "expires_at": 86400
/// }
/// ```
pub async fn create_upload(
State(state): State<Arc<AppState>>,
Json(request): Json<CreateUploadRequest>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
// Validate request
if request.filename.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Filename is required"
})),
)
.into_response();
}
if request.total_size == 0 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Total size must be greater than 0"
})),
)
.into_response();
}
// Validate chunk size if provided
let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
if chunk_size < 1024 * 1024 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Chunk size must be at least 1MB"
})),
)
.into_response();
}
let content_type = request
.content_type
.unwrap_or_else(|| "application/octet-stream".to_string());
match chunked_service
.create_session(
request.filename,
request.folder_id,
content_type,
request.total_size,
Some(chunk_size),
)
.await
{
Ok(response) => (StatusCode::CREATED, Json(response)).into_response(),
Err(e) => {
tracing::error!("Failed to create upload session: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": e.to_string()
})),
)
.into_response()
}
}
}
/// PATCH /api/uploads/:upload_id - Upload a chunk
///
/// Query params:
/// - chunk_index: The index of the chunk (0-based)
/// - checksum: Optional MD5 checksum for verification
///
/// Body: Raw bytes of the chunk
pub async fn upload_chunk(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
Query(params): Query<ChunkUploadParams>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
// Extract checksum from header or query param
let checksum = params.checksum.or_else(|| {
headers
.get("Content-MD5")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
match chunked_service
.upload_chunk(&upload_id, params.chunk_index, body, checksum)
.await
{
Ok(response) => {
let mut resp = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header("Upload-Offset", response.bytes_received.to_string())
.header(
"Upload-Progress",
format!("{:.2}", response.progress * 100.0),
);
if response.is_complete {
resp = resp.header("Upload-Complete", "true");
}
resp.body(axum::body::Body::from(
serde_json::to_string(&response).unwrap(),
))
.unwrap()
.into_response()
}
Err(e) => {
let status = match e.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
Json(serde_json::json!({
"error": e.to_string()
})),
)
.into_response()
}
}
}
/// HEAD /api/uploads/:upload_id - Get upload status
///
/// Returns upload progress and pending chunks
pub async fn get_upload_status(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
match chunked_service.get_status(&upload_id).await {
Ok(status) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header("Upload-Offset", status.bytes_received.to_string())
.header("Upload-Length", status.total_size.to_string())
.header("Upload-Progress", format!("{:.2}", status.progress * 100.0))
.header("Upload-Chunks-Total", status.total_chunks.to_string())
.header(
"Upload-Chunks-Complete",
status.completed_chunks.to_string(),
)
.body(axum::body::Body::from(
serde_json::to_string(&status).unwrap(),
))
.unwrap()
.into_response(),
Err(e) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": e.to_string()
})),
)
.into_response(),
}
}
/// POST /api/uploads/:upload_id/complete - Finalize upload
///
/// Assembles all chunks into the final file and creates the file record
pub async fn complete_upload(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
let upload_service = &state.applications.file_upload_service;
// Assemble chunks
let (assembled_path, filename, folder_id, content_type, total_size) =
match chunked_service.complete_upload(&upload_id).await {
Ok(result) => result,
Err(e) => {
let status = match e.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
ErrorKind::InvalidInput | ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
return (
status,
Json(serde_json::json!({
"error": e.to_string()
})),
)
.into_response();
}
};
// Read assembled file and create final file record
let file_data = match tokio::fs::read(&assembled_path).await {
Ok(data) => data,
Err(e) => {
tracing::error!("Failed to read assembled file: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to read assembled file: {}", e)
})),
)
.into_response();
}
};
// Upload via normal service (this handles path resolution, metadata, etc.)
match upload_service
.upload_file(filename.clone(), folder_id.clone(), content_type, file_data)
.await
{
Ok(file) => {
// Cleanup session
let _ = chunked_service.finalize_upload(&upload_id).await;
tracing::info!(
"✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)",
filename,
file.id,
total_size
);
(
StatusCode::CREATED,
Json(CompleteUploadResponse {
file_id: file.id,
filename: file.name,
size: total_size,
path: file.path,
}),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to create file from assembled upload: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to create file: {:?}", e)
})),
)
.into_response()
}
}
}
/// DELETE /api/uploads/:upload_id - Cancel upload
///
/// Cancels an in-progress upload and cleans up temp files
pub async fn cancel_upload(
State(state): State<Arc<AppState>>,
Path(upload_id): Path<String>,
) -> impl IntoResponse {
let chunked_service = &state.core.chunked_upload_service;
match chunked_service.cancel_upload(&upload_id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": e.to_string()
})),
)
.into_response(),
}
}
}
+441 -428
View File
@@ -1,428 +1,441 @@
use axum::{
extract::{Path, State, Multipart},
http::{StatusCode, header, Response},
response::IntoResponse,
body::Body,
};
use bytes::Bytes;
use serde::Serialize;
use crate::common::di::AppState;
use crate::application::ports::dedup_ports::DedupResultDto;
/// Global application state for dependency injection
type GlobalState = AppState;
/// Response for hash check endpoint
#[derive(Debug, Serialize)]
pub struct HashCheckResponse {
/// Whether a blob with this hash already exists
pub exists: bool,
/// The SHA-256 hash that was checked
pub hash: String,
/// If exists, the size of the existing blob
#[serde(skip_serializing_if = "Option::is_none")]
pub existing_size: Option<u64>,
/// If exists, the number of references to this blob
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_count: Option<u32>,
}
/// Response for upload with dedup endpoint
#[derive(Debug, Serialize)]
pub struct DedupUploadResponse {
/// Whether this was a new file or an existing one
pub is_new: bool,
/// The SHA-256 hash of the content
pub hash: String,
/// The size of the content in bytes
pub size: u64,
/// Bytes saved by deduplication (0 if new file)
pub bytes_saved: u64,
/// Current reference count for this blob
pub ref_count: u32,
}
/// Response for dedup stats endpoint
#[derive(Debug, Serialize)]
pub struct StatsResponse {
/// Total number of unique blobs stored
pub unique_blobs: u64,
/// Total number of references (files pointing to blobs)
pub total_references: u64,
/// Total bytes saved by deduplication
pub bytes_saved: u64,
/// Total logical bytes (what users think they have)
pub total_logical_bytes: u64,
/// Total physical bytes (actual disk usage)
pub total_physical_bytes: u64,
/// Deduplication ratio (logical / physical)
pub dedup_ratio: f64,
/// Percentage of storage saved
pub savings_percentage: f64,
}
/// Handler for deduplication-related endpoints
///
/// Provides endpoints for:
/// - Checking if content already exists (by hash)
/// - Uploading files with automatic deduplication
/// - Getting deduplication statistics
pub struct DedupHandler;
impl DedupHandler {
/// Check if a blob with the given hash already exists
///
/// This endpoint allows clients to check if uploading a file is necessary
/// by pre-computing the hash client-side and checking against the server.
///
/// GET /api/dedup/check/{hash}
pub async fn check_hash(
State(state): State<GlobalState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Validate hash format (SHA-256 = 64 hex chars)
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Invalid hash format. Expected SHA-256 (64 hex characters)"}"#))
.unwrap()
.into_response();
}
match dedup.get_blob_metadata(&hash).await {
Some(metadata) => {
let response = HashCheckResponse {
exists: true,
hash,
existing_size: Some(metadata.size),
ref_count: Some(metadata.ref_count),
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
None => {
let response = HashCheckResponse {
exists: false,
hash,
existing_size: None,
ref_count: None,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
}
}
/// Upload content with automatic deduplication
///
/// This endpoint calculates the SHA-256 hash of the uploaded content
/// and either creates a new blob or increments the reference count
/// of an existing blob.
///
/// POST /api/dedup/upload
///
/// Returns information about whether the content was new or deduplicated.
pub async fn upload_with_dedup(
State(state): State<GlobalState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Process multipart form
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
if name == "file" {
let content_type = field.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Collect all chunks
let mut chunks: Vec<Bytes> = Vec::new();
let mut total_size: usize = 0;
let mut field = field;
while let Ok(Some(chunk)) = field.chunk().await {
total_size += chunk.len();
chunks.push(chunk);
}
if chunks.is_empty() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Empty file not allowed"}"#))
.unwrap()
.into_response();
}
// Combine chunks
let data: Vec<u8> = if chunks.len() == 1 {
chunks.into_iter().next().unwrap().to_vec()
} else {
let mut combined = Vec::with_capacity(total_size);
for chunk in chunks {
combined.extend_from_slice(&chunk);
}
combined
};
// Store with deduplication
match dedup.store_bytes(&data, Some(content_type)).await {
Ok(result) => {
let (is_new, bytes_saved) = match &result {
DedupResultDto::NewBlob { .. } => (true, 0),
DedupResultDto::ExistingBlob { saved_bytes, .. } => (false, *saved_bytes),
};
let metadata = dedup.get_blob_metadata(result.hash()).await;
let response = DedupUploadResponse {
is_new,
hash: result.hash().to_string(),
size: result.size(),
bytes_saved,
ref_count: metadata.map(|m| m.ref_count).unwrap_or(1),
};
tracing::info!(
"🔗 Dedup upload: hash={}, new={}, saved={}",
result.hash(),
is_new,
bytes_saved
);
return Response::builder()
.status(if is_new { StatusCode::CREATED } else { StatusCode::OK })
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response();
}
Err(e) => {
tracing::error!("❌ Dedup upload failed: {}", e);
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"error": "Upload failed: {}"}}"#, e)))
.unwrap()
.into_response();
}
}
}
}
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "No file field found in multipart form"}"#))
.unwrap()
.into_response()
}
/// Get deduplication statistics
///
/// GET /api/dedup/stats
///
/// Returns comprehensive statistics about the deduplication system including:
/// - Number of unique blobs
/// - Total references
/// - Bytes saved
/// - Deduplication ratio
pub async fn get_stats(
State(state): State<GlobalState>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
let stats = dedup.get_stats().await;
// Calculate savings percentage
let savings_pct = if stats.total_bytes_referenced > 0 {
(stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0
} else {
0.0
};
let response = StatsResponse {
unique_blobs: stats.total_blobs,
total_references: stats.dedup_hits + stats.total_blobs, // Approximation
bytes_saved: stats.bytes_saved,
total_logical_bytes: stats.total_bytes_referenced,
total_physical_bytes: stats.total_bytes_stored,
dedup_ratio: stats.dedup_ratio,
savings_percentage: savings_pct,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
/// Retrieve content by hash
///
/// GET /api/dedup/blob/{hash}
///
/// Returns the raw content of a blob identified by its SHA-256 hash.
/// Useful for retrieving deduplicated content.
pub async fn get_blob(
State(state): State<GlobalState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Validate hash format
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Invalid hash format"}"#))
.unwrap()
.into_response();
}
// Get metadata first for content-type
let metadata = dedup.get_blob_metadata(&hash).await;
let content_type = metadata
.as_ref()
.and_then(|m| m.content_type.clone())
.unwrap_or_else(|| "application/octet-stream".to_string());
match dedup.read_blob_bytes(&hash).await {
Ok(content) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, content.len().to_string())
.header("X-Dedup-Hash", &hash)
.body(Body::from(content))
.unwrap()
.into_response()
}
Err(_) => {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Blob not found"}"#))
.unwrap()
.into_response()
}
}
}
/// Remove a reference to a blob
///
/// DELETE /api/dedup/blob/{hash}
///
/// Decrements the reference count for a blob. If the reference count
/// reaches zero, the blob is deleted from storage.
pub async fn remove_reference(
State(state): State<GlobalState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Validate hash format
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Invalid hash format"}"#))
.unwrap()
.into_response();
}
match dedup.remove_reference(&hash).await {
Ok(deleted) => {
let message = if deleted {
format!(r#"{{"success": true, "deleted": true, "message": "Blob {} was deleted (ref_count reached 0)"}}"#, hash)
} else {
format!(r#"{{"success": true, "deleted": false, "message": "Reference removed from blob {}"}}"#, hash)
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(message))
.unwrap()
.into_response()
}
Err(e) => {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"error": "{}"}}"#, e)))
.unwrap()
.into_response()
}
}
}
/// Force recalculation of statistics from disk
///
/// POST /api/dedup/recalculate
///
/// Verifies integrity and returns current statistics.
/// Useful for health checks and auditing.
pub async fn recalculate_stats(
State(state): State<GlobalState>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Verify integrity first
match dedup.verify_integrity().await {
Ok(issues) => {
if !issues.is_empty() {
tracing::warn!("Dedup integrity issues found: {:?}", issues);
}
}
Err(e) => {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"error": "Verification failed: {}"}}"#, e)))
.unwrap()
.into_response();
}
}
let stats = dedup.get_stats().await;
// Calculate savings percentage
let savings_pct = if stats.total_bytes_referenced > 0 {
(stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0
} else {
0.0
};
let response = StatsResponse {
unique_blobs: stats.total_blobs,
total_references: stats.dedup_hits + stats.total_blobs,
bytes_saved: stats.bytes_saved,
total_logical_bytes: stats.total_bytes_referenced,
total_physical_bytes: stats.total_bytes_stored,
dedup_ratio: stats.dedup_ratio,
savings_percentage: savings_pct,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
}
use axum::{
body::Body,
extract::{Multipart, Path, State},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use bytes::Bytes;
use serde::Serialize;
use crate::application::ports::dedup_ports::DedupResultDto;
use crate::common::di::AppState;
/// Global application state for dependency injection
type GlobalState = AppState;
/// Response for hash check endpoint
#[derive(Debug, Serialize)]
pub struct HashCheckResponse {
/// Whether a blob with this hash already exists
pub exists: bool,
/// The SHA-256 hash that was checked
pub hash: String,
/// If exists, the size of the existing blob
#[serde(skip_serializing_if = "Option::is_none")]
pub existing_size: Option<u64>,
/// If exists, the number of references to this blob
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_count: Option<u32>,
}
/// Response for upload with dedup endpoint
#[derive(Debug, Serialize)]
pub struct DedupUploadResponse {
/// Whether this was a new file or an existing one
pub is_new: bool,
/// The SHA-256 hash of the content
pub hash: String,
/// The size of the content in bytes
pub size: u64,
/// Bytes saved by deduplication (0 if new file)
pub bytes_saved: u64,
/// Current reference count for this blob
pub ref_count: u32,
}
/// Response for dedup stats endpoint
#[derive(Debug, Serialize)]
pub struct StatsResponse {
/// Total number of unique blobs stored
pub unique_blobs: u64,
/// Total number of references (files pointing to blobs)
pub total_references: u64,
/// Total bytes saved by deduplication
pub bytes_saved: u64,
/// Total logical bytes (what users think they have)
pub total_logical_bytes: u64,
/// Total physical bytes (actual disk usage)
pub total_physical_bytes: u64,
/// Deduplication ratio (logical / physical)
pub dedup_ratio: f64,
/// Percentage of storage saved
pub savings_percentage: f64,
}
/// Handler for deduplication-related endpoints
///
/// Provides endpoints for:
/// - Checking if content already exists (by hash)
/// - Uploading files with automatic deduplication
/// - Getting deduplication statistics
pub struct DedupHandler;
impl DedupHandler {
/// Check if a blob with the given hash already exists
///
/// This endpoint allows clients to check if uploading a file is necessary
/// by pre-computing the hash client-side and checking against the server.
///
/// GET /api/dedup/check/{hash}
pub async fn check_hash(
State(state): State<GlobalState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Validate hash format (SHA-256 = 64 hex chars)
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"error": "Invalid hash format. Expected SHA-256 (64 hex characters)"}"#,
))
.unwrap()
.into_response();
}
match dedup.get_blob_metadata(&hash).await {
Some(metadata) => {
let response = HashCheckResponse {
exists: true,
hash,
existing_size: Some(metadata.size),
ref_count: Some(metadata.ref_count),
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
None => {
let response = HashCheckResponse {
exists: false,
hash,
existing_size: None,
ref_count: None,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
}
}
/// Upload content with automatic deduplication
///
/// This endpoint calculates the SHA-256 hash of the uploaded content
/// and either creates a new blob or increments the reference count
/// of an existing blob.
///
/// POST /api/dedup/upload
///
/// Returns information about whether the content was new or deduplicated.
pub async fn upload_with_dedup(
State(state): State<GlobalState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Process multipart form
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
if name == "file" {
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Collect all chunks
let mut chunks: Vec<Bytes> = Vec::new();
let mut total_size: usize = 0;
let mut field = field;
while let Ok(Some(chunk)) = field.chunk().await {
total_size += chunk.len();
chunks.push(chunk);
}
if chunks.is_empty() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Empty file not allowed"}"#))
.unwrap()
.into_response();
}
// Combine chunks
let data: Vec<u8> = if chunks.len() == 1 {
chunks.into_iter().next().unwrap().to_vec()
} else {
let mut combined = Vec::with_capacity(total_size);
for chunk in chunks {
combined.extend_from_slice(&chunk);
}
combined
};
// Store with deduplication
match dedup.store_bytes(&data, Some(content_type)).await {
Ok(result) => {
let (is_new, bytes_saved) = match &result {
DedupResultDto::NewBlob { .. } => (true, 0),
DedupResultDto::ExistingBlob { saved_bytes, .. } => {
(false, *saved_bytes)
}
};
let metadata = dedup.get_blob_metadata(result.hash()).await;
let response = DedupUploadResponse {
is_new,
hash: result.hash().to_string(),
size: result.size(),
bytes_saved,
ref_count: metadata.map(|m| m.ref_count).unwrap_or(1),
};
tracing::info!(
"🔗 Dedup upload: hash={}, new={}, saved={}",
result.hash(),
is_new,
bytes_saved
);
return Response::builder()
.status(if is_new {
StatusCode::CREATED
} else {
StatusCode::OK
})
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response();
}
Err(e) => {
tracing::error!("❌ Dedup upload failed: {}", e);
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(
r#"{{"error": "Upload failed: {}"}}"#,
e
)))
.unwrap()
.into_response();
}
}
}
}
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"error": "No file field found in multipart form"}"#,
))
.unwrap()
.into_response()
}
/// Get deduplication statistics
///
/// GET /api/dedup/stats
///
/// Returns comprehensive statistics about the deduplication system including:
/// - Number of unique blobs
/// - Total references
/// - Bytes saved
/// - Deduplication ratio
pub async fn get_stats(State(state): State<GlobalState>) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
let stats = dedup.get_stats().await;
// Calculate savings percentage
let savings_pct = if stats.total_bytes_referenced > 0 {
(stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0
} else {
0.0
};
let response = StatsResponse {
unique_blobs: stats.total_blobs,
total_references: stats.dedup_hits + stats.total_blobs, // Approximation
bytes_saved: stats.bytes_saved,
total_logical_bytes: stats.total_bytes_referenced,
total_physical_bytes: stats.total_bytes_stored,
dedup_ratio: stats.dedup_ratio,
savings_percentage: savings_pct,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
/// Retrieve content by hash
///
/// GET /api/dedup/blob/{hash}
///
/// Returns the raw content of a blob identified by its SHA-256 hash.
/// Useful for retrieving deduplicated content.
pub async fn get_blob(
State(state): State<GlobalState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Validate hash format
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Invalid hash format"}"#))
.unwrap()
.into_response();
}
// Get metadata first for content-type
let metadata = dedup.get_blob_metadata(&hash).await;
let content_type = metadata
.as_ref()
.and_then(|m| m.content_type.clone())
.unwrap_or_else(|| "application/octet-stream".to_string());
match dedup.read_blob_bytes(&hash).await {
Ok(content) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, content.len().to_string())
.header("X-Dedup-Hash", &hash)
.body(Body::from(content))
.unwrap()
.into_response(),
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Blob not found"}"#))
.unwrap()
.into_response(),
}
}
/// Remove a reference to a blob
///
/// DELETE /api/dedup/blob/{hash}
///
/// Decrements the reference count for a blob. If the reference count
/// reaches zero, the blob is deleted from storage.
pub async fn remove_reference(
State(state): State<GlobalState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Validate hash format
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Invalid hash format"}"#))
.unwrap()
.into_response();
}
match dedup.remove_reference(&hash).await {
Ok(deleted) => {
let message = if deleted {
format!(
r#"{{"success": true, "deleted": true, "message": "Blob {} was deleted (ref_count reached 0)"}}"#,
hash
)
} else {
format!(
r#"{{"success": true, "deleted": false, "message": "Reference removed from blob {}"}}"#,
hash
)
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(message))
.unwrap()
.into_response()
}
Err(e) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"error": "{}"}}"#, e)))
.unwrap()
.into_response(),
}
}
/// Force recalculation of statistics from disk
///
/// POST /api/dedup/recalculate
///
/// Verifies integrity and returns current statistics.
/// Useful for health checks and auditing.
pub async fn recalculate_stats(State(state): State<GlobalState>) -> impl IntoResponse {
let dedup = &state.core.dedup_service;
// Verify integrity first
match dedup.verify_integrity().await {
Ok(issues) => {
if !issues.is_empty() {
tracing::warn!("Dedup integrity issues found: {:?}", issues);
}
}
Err(e) => {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(
r#"{{"error": "Verification failed: {}"}}"#,
e
)))
.unwrap()
.into_response();
}
}
let stats = dedup.get_stats().await;
// Calculate savings percentage
let savings_pct = if stats.total_bytes_referenced > 0 {
(stats.bytes_saved as f64 / stats.total_bytes_referenced as f64) * 100.0
} else {
0.0
};
let response = StatsResponse {
unique_blobs: stats.total_blobs,
total_references: stats.dedup_hits + stats.total_blobs,
bytes_saved: stats.bytes_saved,
total_logical_bytes: stats.total_bytes_referenced,
total_physical_bytes: stats.total_bytes_stored,
dedup_ratio: stats.dedup_ratio,
savings_percentage: savings_pct,
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&response).unwrap()))
.unwrap()
.into_response()
}
}
@@ -1,10 +1,10 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use std::sync::Arc;
use tracing::{error, info};
use crate::application::ports::favorites_ports::FavoritesUseCase;
@@ -16,20 +16,21 @@ pub async fn get_favorites(
auth_user: AuthUser,
) -> impl IntoResponse {
let user_id = &auth_user.id;
match favorites_service.get_favorites(user_id).await {
Ok(favorites) => {
info!("Retrieved {} favorites for user", favorites.len());
(StatusCode::OK, Json(serde_json::json!(favorites))).into_response()
},
}
Err(err) => {
error!("Error retrieving favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to retrieve favorites: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
@@ -41,34 +42,37 @@ pub async fn add_favorite(
Path((item_type, item_id)): Path<(String, String)>,
) -> impl IntoResponse {
let user_id = &auth_user.id;
// Validate item_type
if item_type != "file" && item_type != "folder" {
return (
StatusCode::BAD_REQUEST,
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Item type must be 'file' or 'folder'"
}))
})),
);
}
match favorites_service.add_to_favorites(user_id, &item_id, &item_type).await {
match favorites_service
.add_to_favorites(user_id, &item_id, &item_type)
.await
{
Ok(_) => {
info!("Added {} '{}' to favorites", item_type, item_id);
(
StatusCode::CREATED,
StatusCode::CREATED,
Json(serde_json::json!({
"message": "Item added to favorites"
}))
})),
)
},
}
Err(err) => {
error!("Error adding to favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to add to favorites: {}", err)
}))
})),
)
}
}
@@ -81,35 +85,38 @@ pub async fn remove_favorite(
Path((item_type, item_id)): Path<(String, String)>,
) -> impl IntoResponse {
let user_id = &auth_user.id;
match favorites_service.remove_from_favorites(user_id, &item_id, &item_type).await {
match favorites_service
.remove_from_favorites(user_id, &item_id, &item_type)
.await
{
Ok(removed) => {
if removed {
info!("Removed {} '{}' from favorites", item_type, item_id);
(
StatusCode::OK,
StatusCode::OK,
Json(serde_json::json!({
"message": "Item removed from favorites"
}))
})),
)
} else {
info!("Item {} '{}' was not in favorites", item_type, item_id);
(
StatusCode::NOT_FOUND,
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"message": "Item was not in favorites"
}))
})),
)
}
},
}
Err(err) => {
error!("Error removing from favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to remove from favorites: {}", err)
}))
})),
)
}
}
}
}
+360 -227
View File
@@ -1,16 +1,16 @@
use axum::{
extract::{Path, State, Multipart, Query},
http::{StatusCode, header, HeaderMap, Response},
response::IntoResponse,
body::Body,
Json,
body::Body,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
};
use bytes::Bytes;
use http_range_header::parse_range_header;
use serde::Deserialize;
use std::collections::HashMap;
use http_range_header::parse_range_header;
use crate::application::ports::compression_ports::{CompressionPort, CompressionLevel};
use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort};
use crate::application::ports::file_ports::OptimizedFileContent;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::OptionalUserId;
@@ -23,7 +23,7 @@ type GlobalState = AppState;
/**
* API handler for file-related operations.
*
*
* Acts as a thin HTTP adapter in the hexagonal architecture: it parses requests,
* delegates business logic to application services, and maps results to HTTP
* responses. No infrastructure or strategy logic lives here.
@@ -36,7 +36,7 @@ impl FileHandler {
// ═══════════════════════════════════════════════════════════════════════
/// Uploads a file with TRUE STREAMING support and Write-Behind Cache
///
///
/// The three-tier strategy (write-behind / buffered / streaming) and dedup
/// are fully handled by `FileUploadUseCase::smart_upload`.
/// This handler only extracts multipart fields and maps the result to HTTP.
@@ -53,13 +53,18 @@ impl FileHandler {
if name == "folder_id" {
let v = field.text().await.unwrap_or_default();
if !v.is_empty() { folder_id = Some(v); }
if !v.is_empty() {
folder_id = Some(v);
}
continue;
}
if name == "file" {
let filename = field.file_name().unwrap_or("unnamed").to_string();
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Collect chunks from multipart
let mut chunks: Vec<Bytes> = Vec::new();
@@ -73,7 +78,10 @@ impl FileHandler {
// Empty file
if chunks.is_empty() {
let upload_service = &state.applications.file_upload_service;
return match upload_service.upload_file(filename, folder_id, content_type, vec![]).await {
return match upload_service
.upload_file(filename, folder_id, content_type, vec![])
.await
{
Ok(file) => Self::created_json_response(&file).into_response(),
Err(err) => Self::domain_error_response(err).into_response(),
};
@@ -82,7 +90,10 @@ impl FileHandler {
// Delegate to FileService (simple path, no write-behind/dedup)
let upload_service = &state.applications.file_upload_service;
let data = Self::combine_chunks(chunks, total_size);
match upload_service.upload_file(filename.clone(), folder_id, content_type, data).await {
match upload_service
.upload_file(filename.clone(), folder_id, content_type, data)
.await
{
Ok(file) => {
tracing::info!("✅ UPLOAD COMPLETE: {} (ID: {})", filename, file.id);
return Self::created_json_response(&file);
@@ -95,9 +106,13 @@ impl FileHandler {
}
}
(StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "No file provided"
}))).into_response()
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file provided"
})),
)
.into_response()
}
/// Uploads a file with Write-Behind Cache + Dedup (smart strategy).
@@ -118,13 +133,18 @@ impl FileHandler {
if name == "folder_id" {
let v = field.text().await.unwrap_or_default();
if !v.is_empty() { folder_id = Some(v); }
if !v.is_empty() {
folder_id = Some(v);
}
continue;
}
if name == "file" {
let filename = field.file_name().unwrap_or("unnamed").to_string();
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Collect chunks
let mut chunks: Vec<Bytes> = Vec::new();
@@ -138,7 +158,10 @@ impl FileHandler {
// Empty file
if chunks.is_empty() {
let upload_svc = &state.applications.file_upload_service;
return match upload_svc.upload_file(filename, folder_id, content_type, vec![]).await {
return match upload_svc
.upload_file(filename, folder_id, content_type, vec![])
.await
{
Ok(file) => Self::created_json_response(&file).into_response(),
Err(err) => Self::domain_error_response(err).into_response(),
};
@@ -146,13 +169,22 @@ impl FileHandler {
// Delegate to smart_upload (handles write-behind, dedup, streaming)
match upload_service
.smart_upload(filename.clone(), folder_id, content_type, chunks, total_size)
.smart_upload(
filename.clone(),
folder_id,
content_type,
chunks,
total_size,
)
.await
{
Ok((file, strategy)) => {
tracing::info!(
"✅ SMART UPLOAD: {} ({} bytes, strategy: {:?}, ID: {})",
filename, total_size, strategy, file.id
filename,
total_size,
strategy,
file.id
);
return Self::created_json_response(&file).into_response();
}
@@ -164,9 +196,13 @@ impl FileHandler {
}
}
(StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "No file provided"
}))).into_response()
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file provided"
})),
)
.into_response()
}
// ═══════════════════════════════════════════════════════════════════════
@@ -191,31 +227,46 @@ impl FileHandler {
"preview" => ThumbnailSize::Preview,
"large" => ThumbnailSize::Large,
_ => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Invalid thumbnail size. Use: icon, preview, or large"
}))).into_response();
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid thumbnail size. Use: icon, preview, or large"
})),
)
.into_response();
}
};
let file = match file_retrieval_service.get_file(&id).await {
Ok(f) => f,
Err(err) => {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": format!("File not found: {}", err)
}))).into_response();
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("File not found: {}", err)
})),
)
.into_response();
}
};
if !thumbnail_service.is_supported_image(&file.mime_type) {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "File is not a supported image type"
}))).into_response();
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "File is not a supported image type"
})),
)
.into_response();
}
let storage_root = state.core.path_service.get_root_path();
let file_path = storage_root.join(&file.path);
match thumbnail_service.get_thumbnail(&id, thumb_size, &file_path).await {
match thumbnail_service
.get_thumbnail(&id, thumb_size, &file_path)
.await
{
Ok(data) => {
let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size);
Response::builder()
@@ -230,9 +281,13 @@ impl FileHandler {
}
Err(err) => {
tracing::error!("Thumbnail generation failed: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Failed to generate thumbnail: {}", err)
}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to generate thumbnail: {}", err)
})),
)
.into_response()
}
}
}
@@ -259,29 +314,42 @@ impl FileHandler {
let file_dto = match retrieval.get_file(&id).await {
Ok(f) => f,
Err(err) => {
let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") {
let status = if err.to_string().contains("not found")
|| err.to_string().contains("NotFound")
{
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
return (status, Json(serde_json::json!({
"error": err.to_string()
}))).into_response();
return (
status,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response();
}
};
// ── Metadata-only request ────────────────────────────────────
if params.get("metadata").is_some_and(|v| v == "true" || v == "1") {
return (StatusCode::OK, Json(serde_json::json!({
"id": file_dto.id,
"name": file_dto.name,
"path": file_dto.path,
"size": file_dto.size,
"mime_type": file_dto.mime_type,
"folder_id": file_dto.folder_id,
"created_at": file_dto.created_at,
"modified_at": file_dto.modified_at
}))).into_response();
if params
.get("metadata")
.is_some_and(|v| v == "true" || v == "1")
{
return (
StatusCode::OK,
Json(serde_json::json!({
"id": file_dto.id,
"name": file_dto.name,
"path": file_dto.path,
"size": file_dto.size,
"mime_type": file_dto.mime_type,
"folder_id": file_dto.folder_id,
"created_at": file_dto.created_at,
"modified_at": file_dto.modified_at
})),
)
.into_response();
}
let etag = format!("\"{}-{}\"", id, file_dto.modified_at);
@@ -289,112 +357,137 @@ impl FileHandler {
// ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& (client_etag == etag || client_etag == "*") {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.body(Body::empty())
.unwrap()
.into_response();
}
&& (client_etag == etag || client_etag == "*")
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.body(Body::empty())
.unwrap()
.into_response();
}
// ── Range Requests ───────────────────────────────────────────
if let Some(range_header) = headers.get(header::RANGE)
&& let Ok(range_str) = range_header.to_str()
&& let Ok(ranges) = parse_range_header(range_str) {
let validated = ranges.validate(file_dto.size);
if let Ok(valid_ranges) = validated {
if let Some(range) = valid_ranges.first() {
let start = *range.start();
let end = *range.end();
let range_length = end - start + 1;
let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
&& let Ok(ranges) = parse_range_header(range_str)
{
let validated = ranges.validate(file_dto.size);
if let Ok(valid_ranges) = validated {
if let Some(range) = valid_ranges.first() {
let start = *range.start();
let end = *range.end();
let range_length = end - start + 1;
let disposition =
Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
match retrieval.get_file_range_stream(&id, start, Some(end + 1)).await {
Ok(stream) => {
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, range_length)
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_dto.size))
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, &etag)
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
.body(Body::from_stream(Box::into_pin(stream)))
.unwrap()
.into_response();
}
Err(err) => {
tracing::error!("Error creating range stream: {}", err);
// fall through to normal download
}
}
match retrieval
.get_file_range_stream(&id, start, Some(end + 1))
.await
{
Ok(stream) => {
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file_dto.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.body(Body::from_stream(Box::into_pin(stream)))
.unwrap()
.into_response();
}
Err(err) => {
tracing::error!("Error creating range stream: {}", err);
// fall through to normal download
}
} else {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size))
.body(Body::empty())
.unwrap()
.into_response();
}
}
} else {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size))
.body(Body::empty())
.unwrap()
.into_response();
}
}
// ── Normal download (delegated to service) ───────────────────
let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
let accept_webp = headers.get(header::ACCEPT)
let accept_webp = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("image/webp"));
let prefer_original = params.get("original").is_some_and(|v| v == "true" || v == "1");
let prefer_original = params
.get("original")
.is_some_and(|v| v == "true" || v == "1");
match retrieval.get_file_optimized(&id, accept_webp, prefer_original).await {
match retrieval
.get_file_optimized(&id, accept_webp, prefer_original)
.await
{
Ok((_file, content)) => match content {
OptimizedFileContent::Bytes { data, mime_type, .. } => {
Self::build_cached_response(
data,
&mime_type,
&disposition,
&etag,
file_dto.size,
&params,
&*state.core.compression_service,
).await
.into_response()
}
OptimizedFileContent::Mmap(mmap_data) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, mmap_data.len())
.header(header::ETAG, &etag)
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from(mmap_data))
.unwrap()
.into_response()
}
OptimizedFileContent::Stream(pinned_stream) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, file_dto.size)
.header(header::ETAG, &etag)
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(pinned_stream))
.unwrap()
.into_response()
}
OptimizedFileContent::Bytes {
data, mime_type, ..
} => Self::build_cached_response(
data,
&mime_type,
&disposition,
&etag,
file_dto.size,
&params,
&*state.core.compression_service,
)
.await
.into_response(),
OptimizedFileContent::Mmap(mmap_data) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, mmap_data.len())
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from(mmap_data))
.unwrap()
.into_response(),
OptimizedFileContent::Stream(pinned_stream) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, file_dto.size)
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(pinned_stream))
.unwrap()
.into_response(),
},
Err(err) => {
tracing::error!("Error downloading file: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Error reading file: {}", err)
}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Error reading file: {}", err)
})),
)
.into_response()
}
}
}
@@ -421,9 +514,13 @@ impl FileHandler {
}
Err(err) => {
tracing::error!("Error listing files: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Error listing files: {}", err)
}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Error listing files: {}", err)
})),
)
.into_response()
}
}
}
@@ -440,39 +537,38 @@ impl FileHandler {
let response = Self::upload_file_with_cache(State(state.clone()), multipart).await;
// Try to extract file info for thumbnail generation
if let Ok(body_bytes) = axum::body::to_bytes(
response.into_response().into_body(),
10 * 1024,
).await
if let Ok(body_bytes) =
axum::body::to_bytes(response.into_response().into_body(), 10 * 1024).await
&& let Ok(file_info) = serde_json::from_slice::<serde_json::Value>(&body_bytes)
&& let (Some(file_id), Some(mime_type), Some(file_path_str)) = (
file_info.get("id").and_then(|v| v.as_str()),
file_info.get("mime_type").and_then(|v| v.as_str()),
file_info.get("path").and_then(|v| v.as_str()),
) {
// Generate thumbnails for images in background
if state.core.thumbnail_service.is_supported_image(mime_type) {
let file_id = file_id.to_string();
let file_path_rel = file_path_str.to_string();
let thumbnail_service = state.core.thumbnail_service.clone();
let path_service = state.core.path_service.clone();
&& let (Some(file_id), Some(mime_type), Some(file_path_str)) = (
file_info.get("id").and_then(|v| v.as_str()),
file_info.get("mime_type").and_then(|v| v.as_str()),
file_info.get("path").and_then(|v| v.as_str()),
)
{
// Generate thumbnails for images in background
if state.core.thumbnail_service.is_supported_image(mime_type) {
let file_id = file_id.to_string();
let file_path_rel = file_path_str.to_string();
let thumbnail_service = state.core.thumbnail_service.clone();
let path_service = state.core.path_service.clone();
tokio::spawn(async move {
let file_path = path_service.get_root_path().join(&file_path_rel);
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
thumbnail_service.generate_all_sizes_background(file_id, file_path);
});
}
tokio::spawn(async move {
let file_path = path_service.get_root_path().join(&file_path_rel);
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
thumbnail_service.generate_all_sizes_background(file_id, file_path);
});
}
// Return the response
return Response::builder()
.status(StatusCode::CREATED)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.body(Body::from(body_bytes))
.unwrap()
.into_response();
}
// Return the response
return Response::builder()
.status(StatusCode::CREATED)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.body(Body::from(body_bytes))
.unwrap()
.into_response();
}
// Fallback for errors
(StatusCode::INTERNAL_SERVER_ERROR, "Upload processing error").into_response()
@@ -499,9 +595,13 @@ impl FileHandler {
}
Err(err) => {
tracing::error!("Error listing files: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": err.to_string()
}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
}
}
}
@@ -526,13 +626,15 @@ impl FileHandler {
let result = if let Some(uid) = user_id {
// Auth available: trash-first with dedup cleanup
mgmt.delete_with_cleanup(&id, &uid).await.map(|was_trashed| {
if was_trashed {
tracing::info!("File moved to trash: {}", id);
} else {
tracing::info!("File permanently deleted: {}", id);
}
})
mgmt.delete_with_cleanup(&id, &uid)
.await
.map(|was_trashed| {
if was_trashed {
tracing::info!("File moved to trash: {}", id);
} else {
tracing::info!("File permanently deleted: {}", id);
}
})
} else {
// No auth: permanent delete
tracing::warn!("No auth context – permanently deleting file: {}", id);
@@ -545,14 +647,20 @@ impl FileHandler {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => {
tracing::error!("Error deleting file: {}", err);
let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") {
let status = if err.to_string().contains("not found")
|| err.to_string().contains("NotFound")
{
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(status, Json(serde_json::json!({
"error": format!("Error deleting file: {}", err)
}))).into_response()
(
status,
Json(serde_json::json!({
"error": format!("Error deleting file: {}", err)
})),
)
.into_response()
}
}
}
@@ -570,9 +678,13 @@ impl FileHandler {
let new_name = match payload.get("name").and_then(|v| v.as_str()) {
Some(name) if !name.trim().is_empty() => name.trim().to_string(),
_ => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Missing or empty 'name' field"
}))).into_response();
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Missing or empty 'name' field"
})),
)
.into_response();
}
};
@@ -582,16 +694,22 @@ impl FileHandler {
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => {
tracing::error!("Error renaming file: {}", err);
let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") {
let status = if err.to_string().contains("not found")
|| err.to_string().contains("NotFound")
{
StatusCode::NOT_FOUND
} else if err.to_string().contains("already exists") {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(status, Json(serde_json::json!({
"error": format!("Error renaming file: {}", err)
}))).into_response()
(
status,
Json(serde_json::json!({
"error": format!("Error renaming file: {}", err)
})),
)
.into_response()
}
}
}
@@ -608,22 +726,28 @@ impl FileHandler {
let mgmt = &state.applications.file_management_service;
match retrieval.get_file(&id).await {
Ok(_) => {
match mgmt.move_file(&id, payload.folder_id).await {
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
Err(err) => {
tracing::error!("Error moving file: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
Ok(_) => match mgmt.move_file(&id, payload.folder_id).await {
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
Err(err) => {
tracing::error!("Error moving file: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Error moving file: {}", err)
}))).into_response()
}
})),
)
.into_response()
}
}
},
Err(err) => {
tracing::error!("File not found for move: {}", err);
(StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": format!("File with ID {} does not exist", id)
}))).into_response()
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("File with ID {} does not exist", id)
})),
)
.into_response()
}
}
}
@@ -644,9 +768,13 @@ impl FileHandler {
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => {
tracing::error!("Error moving file: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Error moving file: {}", err)
}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Error moving file: {}", err)
})),
)
.into_response()
}
}
}
@@ -670,7 +798,9 @@ impl FileHandler {
/// Build a Content-Disposition header value.
fn content_disposition(name: &str, mime: &str, params: &HashMap<String, String>) -> String {
let force_inline = params.get("inline").is_some_and(|v| v == "true" || v == "1");
let force_inline = params
.get("inline")
.is_some_and(|v| v == "true" || v == "1");
if force_inline
|| mime.starts_with("image/")
|| mime == "application/pdf"
@@ -720,7 +850,8 @@ impl FileHandler {
) -> Response<Body> {
let compression_param = params.get("compress").map(|v| v.as_str());
let force_compress = compression_param == Some("true") || compression_param == Some("1");
let force_no_compress = compression_param == Some("false") || compression_param == Some("0");
let force_no_compress =
compression_param == Some("false") || compression_param == Some("0");
let should_compress = if force_no_compress {
false
@@ -740,26 +871,28 @@ impl FileHandler {
.status(StatusCode::OK)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::VARY, "Accept-Encoding");
if should_compress {
match compression_service.compress_data(&content, compression_level).await {
Ok(compressed) => {
builder
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_ENCODING, "gzip")
.header(header::CONTENT_LENGTH, compressed.len())
.body(Body::from(compressed))
.unwrap()
}
Err(_) => {
builder
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, content.len())
.body(Body::from(content))
.unwrap()
}
match compression_service
.compress_data(&content, compression_level)
.await
{
Ok(compressed) => builder
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_ENCODING, "gzip")
.header(header::CONTENT_LENGTH, compressed.len())
.body(Body::from(compressed))
.unwrap(),
Err(_) => builder
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, content.len())
.body(Body::from(content))
.unwrap(),
}
} else {
builder
@@ -776,4 +909,4 @@ impl FileHandler {
pub struct MoveFilePayload {
/// Target folder ID (None means root)
pub folder_id: Option<String>,
}
}
+133 -90
View File
@@ -1,19 +1,19 @@
use std::sync::Arc;
use std::collections::HashMap;
use axum::{
extract::{Path, State, Query},
http::{StatusCode, header, HeaderName, HeaderValue, Response},
response::IntoResponse,
Json,
extract::{Path, Query, State},
http::{HeaderName, HeaderValue, Response, StatusCode, header},
response::IntoResponse,
};
use std::collections::HashMap;
use std::sync::Arc;
use crate::application::services::folder_service::FolderService;
use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto};
use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto};
use crate::application::dtos::pagination::PaginationRequestDto;
use crate::common::errors::ErrorKind;
use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState;
use crate::interfaces::middleware::auth::{OptionalAuthUser, AuthUser};
use crate::common::errors::ErrorKind;
use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser};
type AppState = Arc<FolderService>;
@@ -34,12 +34,12 @@ impl FolderHandler {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, err.to_string()).into_response()
}
}
}
/// Gets a folder by ID
pub async fn get_folder(
State(service): State<AppState>,
@@ -52,12 +52,12 @@ impl FolderHandler {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, err.to_string()).into_response()
}
}
}
/// Lists root folders (no parent ID)
/// Non-admin users only see their own home folder.
pub async fn list_root_folders(
@@ -111,18 +111,20 @@ impl FolderHandler {
parent_id: Option<&str>,
) -> axum::response::Response {
match service.list_folders(parent_id).await {
Ok(folders) => {
(StatusCode::OK, Json(folders)).into_response()
},
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
Err(err) => {
let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(serde_json::json!({
"error": err.to_string()
}))).into_response()
(
status,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
}
}
}
@@ -138,35 +140,42 @@ impl FolderHandler {
Ok(folders) => {
// Only filter at root level (parent_id == None)
let filtered = if parent_id.is_none() {
folders.into_iter().filter(|f| {
// Skip hidden/system folders
if f.name.starts_with('.') {
return false;
}
// If it's a user home folder, only show if it belongs to this user
if Self::is_user_home_folder(&f.name) {
return Self::folder_belongs_to_user(&f.name, &auth_user.username);
}
// Non-home folders are visible to everyone
true
}).collect()
folders
.into_iter()
.filter(|f| {
// Skip hidden/system folders
if f.name.starts_with('.') {
return false;
}
// If it's a user home folder, only show if it belongs to this user
if Self::is_user_home_folder(&f.name) {
return Self::folder_belongs_to_user(&f.name, &auth_user.username);
}
// Non-home folders are visible to everyone
true
})
.collect()
} else {
folders
};
(StatusCode::OK, Json(filtered)).into_response()
},
}
Err(err) => {
let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(serde_json::json!({
"error": err.to_string()
}))).into_response()
(
status,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
}
}
}
/// Lists folders with pagination support (internal helper)
async fn list_folders_paginated_inner(
service: AppState,
@@ -174,23 +183,25 @@ impl FolderHandler {
parent_id: Option<&str>,
) -> axum::response::Response {
match service.list_folders_paginated(parent_id, &pagination).await {
Ok(paginated_result) => {
(StatusCode::OK, Json(paginated_result)).into_response()
},
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
Err(err) => {
let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
// Return a JSON error response
(status, Json(serde_json::json!({
"error": err.to_string()
}))).into_response()
(
status,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
}
}
}
/// Renames a folder
pub async fn rename_folder(
State(service): State<AppState>,
@@ -205,15 +216,19 @@ impl FolderHandler {
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
// Return a proper JSON error response
(status, Json(serde_json::json!({
"error": err.to_string()
}))).into_response()
(
status,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response()
}
}
}
/// Moves a folder to a new parent
pub async fn move_folder(
State(service): State<AppState>,
@@ -228,12 +243,12 @@ impl FolderHandler {
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, err.to_string()).into_response()
}
}
}
/// Deletes a folder (with trash support)
pub async fn delete_folder(
State(service): State<AppState>,
@@ -247,58 +262,68 @@ impl FolderHandler {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, err.to_string()).into_response()
}
}
}
/// Deletes a folder with trash functionality
pub async fn delete_folder_with_trash(
State(state): State<GlobalAppState>,
OptionalAuthUser(auth_user): OptionalAuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous");
let user_id = auth_user
.as_ref()
.map(|u| u.id.as_str())
.unwrap_or("anonymous");
// Check if trash service is available
if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id);
// Try to move to trash first
match trash_service.move_to_trash(&id, "folder", user_id).await {
Ok(_) => {
tracing::info!("Folder successfully moved to trash: {}", id);
return StatusCode::NO_CONTENT.into_response();
},
}
Err(err) => {
tracing::warn!("Could not move folder to trash, falling back to permanent delete: {}", err);
tracing::warn!(
"Could not move folder to trash, falling back to permanent delete: {}",
err
);
// Fall through to regular delete if trash fails
}
}
}
// Fallback to permanent delete if trash is unavailable or failed
let folder_service = &state.applications.folder_service;
match folder_service.delete_folder(&id).await {
Ok(_) => {
tracing::info!("Folder permanently deleted: {}", id);
StatusCode::NO_CONTENT.into_response()
},
}
Err(err) => {
tracing::error!("Error deleting folder: {}", err);
let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(serde_json::json!({
"error": format!("Error deleting folder: {}", err)
}))).into_response()
(
status,
Json(serde_json::json!({
"error": format!("Error deleting folder: {}", err)
})),
)
.into_response()
}
}
}
/// Downloads a folder as a ZIP file
pub async fn download_folder_zip(
State(state): State<GlobalAppState>,
@@ -306,67 +331,85 @@ impl FolderHandler {
Query(_params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
tracing::info!("Downloading folder as ZIP: {}", id);
// Get folder information first to check it exists and get name
let folder_service = &state.applications.folder_service;
match folder_service.get_folder(&id).await {
Ok(folder) => {
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
// Use ZIP service from DI container
let zip_service = &state.core.zip_service;
// Create the ZIP file
match zip_service.create_folder_zip(&id, &folder.name).await {
Ok(zip_data) => {
tracing::info!("ZIP file created successfully, size: {} bytes", zip_data.len());
tracing::info!(
"ZIP file created successfully, size: {} bytes",
zip_data.len()
);
// Setup headers for download
let filename = format!("{}.zip", folder.name);
let content_disposition = format!("attachment; filename=\"{}\"", filename);
// Build response with the ZIP data
let mut headers = HashMap::new();
headers.insert(header::CONTENT_TYPE.to_string(), "application/zip".to_string());
headers.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition);
headers.insert(header::CONTENT_LENGTH.to_string(), zip_data.len().to_string());
headers.insert(
header::CONTENT_TYPE.to_string(),
"application/zip".to_string(),
);
headers
.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition);
headers.insert(
header::CONTENT_LENGTH.to_string(),
zip_data.len().to_string(),
);
// Build the response
let mut response = Response::builder()
.status(StatusCode::OK)
.body(axum::body::Body::from(zip_data))
.unwrap();
// Add headers to response
for (name, value) in headers {
response.headers_mut().insert(
HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(&value).unwrap()
HeaderValue::from_str(&value).unwrap(),
);
}
response
},
}
Err(err) => {
tracing::error!("Error creating ZIP file: {}", err);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Error creating ZIP file: {}", err)
}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Error creating ZIP file: {}", err)
})),
)
.into_response()
}
}
},
}
Err(err) => {
tracing::error!("Folder not found: {}", err);
let status = match err.kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(serde_json::json!({
"error": format!("Error finding folder: {}", err)
}))).into_response()
(
status,
Json(serde_json::json!({
"error": format!("Error finding folder: {}", err)
})),
)
.into_response()
}
}
}
}
}
+40 -34
View File
@@ -1,14 +1,16 @@
use std::sync::Arc;
use axum::{
extract::{State, Query, Path},
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use std::sync::Arc;
use crate::application::dtos::i18n_dto::{
LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto,
};
use crate::application::services::i18n_application_service::I18nApplicationService;
use crate::application::dtos::i18n_dto::{LocaleDto, TranslationRequestDto, TranslationResponseDto, TranslationErrorDto};
use crate::domain::services::i18n_service::{Locale, I18nError};
use crate::domain::services::i18n_service::{I18nError, Locale};
type AppState = Arc<I18nApplicationService>;
@@ -17,37 +19,33 @@ pub struct I18nHandler;
impl I18nHandler {
/// Gets a list of available locales
pub async fn get_locales(
State(service): State<AppState>,
) -> impl IntoResponse {
pub async fn get_locales(State(service): State<AppState>) -> impl IntoResponse {
let locales = service.available_locales().await;
let locale_dtos: Vec<LocaleDto> = locales.into_iter().map(LocaleDto::from).collect();
(StatusCode::OK, Json(locale_dtos)).into_response()
}
/// Translates a key to the requested locale
pub async fn translate(
State(service): State<AppState>,
Query(query): Query<TranslationRequestDto>,
) -> impl IntoResponse {
let locale = match &query.locale {
Some(locale_str) => {
match Locale::from_str(locale_str) {
Some(locale) => Some(locale),
None => {
let error = TranslationErrorDto {
key: query.key.clone(),
locale: locale_str.clone(),
error: format!("Unsupported locale: {}", locale_str),
};
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
Some(locale_str) => match Locale::from_str(locale_str) {
Some(locale) => Some(locale),
None => {
let error = TranslationErrorDto {
key: query.key.clone(),
locale: locale_str.clone(),
error: format!("Unsupported locale: {}", locale_str),
};
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
},
None => None,
};
match service.translate(&query.key, locale).await {
Ok(text) => {
let response = TranslationResponseDto {
@@ -56,25 +54,25 @@ impl I18nHandler {
text,
};
(StatusCode::OK, Json(response)).into_response()
},
}
Err(err) => {
let status = match &err {
I18nError::KeyNotFound(_) => StatusCode::NOT_FOUND,
I18nError::InvalidLocale(_) => StatusCode::BAD_REQUEST,
I18nError::LoadError(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let error = TranslationErrorDto {
key: query.key,
locale: locale.unwrap_or(Locale::default()).as_str().to_string(),
error: err.to_string(),
};
(status, Json(error)).into_response()
}
}
}
/// Gets all translations for a locale (Axum-compatible: extracts locale from path)
pub async fn get_translations_by_locale(
State(service): State<AppState>,
@@ -91,16 +89,24 @@ impl I18nHandler {
let locale = match Locale::from_str(&locale_code) {
Some(locale) => locale,
None => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": format!("Unsupported locale: {}", locale_code)
}))).into_response();
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("Unsupported locale: {}", locale_code)
})),
)
.into_response();
}
};
// This implementation is a bit weird, as we don't have a way to get all translations
// We should improve the I18nService to support this
(StatusCode::OK, Json(serde_json::json!({
"locale": locale.as_str()
}))).into_response()
(
StatusCode::OK,
Json(serde_json::json!({
"locale": locale.as_str()
})),
)
.into_response()
}
}
}
+11 -11
View File
@@ -1,19 +1,19 @@
pub mod admin_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod i18n_handler;
pub mod batch_handler;
pub mod auth_handler;
pub mod trash_handler;
pub mod search_handler;
pub mod share_handler;
pub mod favorites_handler;
pub mod recent_handler;
pub mod webdav_handler;
pub mod batch_handler;
pub mod caldav_handler;
pub mod carddav_handler;
pub mod chunked_upload_handler;
pub mod dedup_handler;
pub mod favorites_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod i18n_handler;
pub mod recent_handler;
pub mod search_handler;
pub mod share_handler;
pub mod trash_handler;
pub mod webdav_handler;
/// Tipo de resultado para controladores de API
pub type ApiResult<T> = Result<T, (axum::http::StatusCode, String)>;
pub type ApiResult<T> = Result<T, (axum::http::StatusCode, String)>;
+57 -42
View File
@@ -1,11 +1,11 @@
use std::sync::Arc;
use axum::{
extract::{Path, State, Query},
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use crate::application::ports::recent_ports::RecentItemsUseCase;
@@ -25,20 +25,21 @@ pub async fn get_recent_items(
Query(params): Query<GetRecentParams>,
) -> impl IntoResponse {
let user_id = &auth_user.id;
match recent_service.get_recent_items(user_id, params.limit).await {
Ok(items) => {
info!("Retrieved {} recent items for user", items.len());
(StatusCode::OK, Json(items)).into_response()
},
}
Err(err) => {
error!("Error retrieving recent items: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to retrieve recent items: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
@@ -50,35 +51,41 @@ pub async fn record_item_access(
Path((item_type, item_id)): Path<(String, String)>,
) -> impl IntoResponse {
let user_id = &auth_user.id;
// Validate item type
if item_type != "file" && item_type != "folder" {
return (
StatusCode::BAD_REQUEST,
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Item type must be 'file' or 'folder'"
}))
).into_response();
})),
)
.into_response();
}
match recent_service.record_item_access(user_id, &item_id, &item_type).await {
match recent_service
.record_item_access(user_id, &item_id, &item_type)
.await
{
Ok(_) => {
info!("Recorded access to {} '{}' in recents", item_type, item_id);
(
StatusCode::OK,
StatusCode::OK,
Json(serde_json::json!({
"message": "Access recorded successfully"
}))
).into_response()
},
})),
)
.into_response()
}
Err(err) => {
error!("Error recording access in recents: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to record access: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
@@ -90,35 +97,41 @@ pub async fn remove_from_recent(
Path((item_type, item_id)): Path<(String, String)>,
) -> impl IntoResponse {
let user_id = &auth_user.id;
match recent_service.remove_from_recent(user_id, &item_id, &item_type).await {
match recent_service
.remove_from_recent(user_id, &item_id, &item_type)
.await
{
Ok(removed) => {
if removed {
info!("Removed {} '{}' from recents", item_type, item_id);
(
StatusCode::OK,
StatusCode::OK,
Json(serde_json::json!({
"message": "Item removed from recents"
}))
).into_response()
})),
)
.into_response()
} else {
info!("Item {} '{}' was not in recents", item_type, item_id);
(
StatusCode::NOT_FOUND,
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"message": "Item was not in recents"
}))
).into_response()
})),
)
.into_response()
}
},
}
Err(err) => {
error!("Error removing from recents: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to remove from recents: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
@@ -129,25 +142,27 @@ pub async fn clear_recent_items(
auth_user: AuthUser,
) -> impl IntoResponse {
let user_id = &auth_user.id;
match recent_service.clear_recent_items(user_id).await {
Ok(_) => {
info!("Cleared all recent items for user");
(
StatusCode::OK,
StatusCode::OK,
Json(serde_json::json!({
"message": "Recent items cleared successfully"
}))
).into_response()
},
})),
)
.into_response()
}
Err(err) => {
error!("Error clearing recent items: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to clear recent items: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
}
+69 -56
View File
@@ -1,17 +1,17 @@
use axum::{
extract::{State, Query, Json},
response::IntoResponse,
extract::{Json, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde_json::json;
use tracing::{info, error};
use tracing::{error, info};
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::common::di::AppState;
/**
* Handler for search operations through the API.
*
*
* This handler exposes endpoints related to search functionality,
* allowing users to search for files and folders using various criteria.
*/
@@ -20,9 +20,9 @@ pub struct SearchHandler;
impl SearchHandler {
/**
* Performs a search based on the criteria provided as query parameters.
*
*
* This endpoint allows simple searches directly with URL parameters.
*
*
* @param state Application state with services
* @param query_params Search parameters as query string
* @return HTTP response with the search results
@@ -32,7 +32,7 @@ impl SearchHandler {
Query(params): Query<SearchParams>,
) -> impl IntoResponse {
info!("API: File search with parameters: {:?}", params);
// Extract the search service or return error if not available
let search_service = match &state.applications.search_service {
Some(service) => service,
@@ -42,15 +42,18 @@ impl SearchHandler {
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "Search service is not available"
}))
).into_response();
})),
)
.into_response();
}
};
// Convert search parameters to DTO
let search_criteria = SearchCriteriaDto {
name_contains: params.query,
file_types: params.type_filter.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()),
file_types: params
.type_filter
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()),
created_after: params.created_after,
created_before: params.created_before,
modified_after: params.modified_after,
@@ -62,32 +65,36 @@ impl SearchHandler {
limit: params.limit.unwrap_or(100),
offset: params.offset.unwrap_or(0),
};
// Perform the search
match search_service.search(search_criteria).await {
Ok(results) => {
info!("Search completed, {} files and {} folders found",
results.files.len(), results.folders.len());
info!(
"Search completed, {} files and {} folders found",
results.files.len(),
results.folders.len()
);
(StatusCode::OK, Json(results)).into_response()
},
}
Err(err) => {
error!("Search error: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Search error: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
/**
* Performs an advanced search based on a complete JSON criteria object.
*
*
* This endpoint allows more complex searches with all possible criteria
* provided in the request body.
*
*
* @param state Application state with services
* @param criteria Complete search criteria
* @return HTTP response with the search results
@@ -97,7 +104,7 @@ impl SearchHandler {
Json(criteria): Json<SearchCriteriaDto>,
) -> impl IntoResponse {
info!("API: Advanced file search");
// Extract the search service or return error if not available
let search_service = match &state.applications.search_service {
Some(service) => service,
@@ -107,44 +114,47 @@ impl SearchHandler {
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "Search service is not available"
}))
).into_response();
})),
)
.into_response();
}
};
// Perform the search
match search_service.search(criteria).await {
Ok(results) => {
info!("Search completed, {} files and {} folders found",
results.files.len(), results.folders.len());
info!(
"Search completed, {} files and {} folders found",
results.files.len(),
results.folders.len()
);
(StatusCode::OK, Json(results)).into_response()
},
}
Err(err) => {
error!("Search error: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Search error: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
/**
* Clears the search results cache.
*
*
* This endpoint is useful for forcing fresh searches after significant
* changes in the file system.
*
*
* @param state Application state with services
* @return HTTP response indicating success or error
*/
pub async fn clear_search_cache(
State(state): State<AppState>,
) -> impl IntoResponse {
pub async fn clear_search_cache(State(state): State<AppState>) -> impl IntoResponse {
info!("API: Clearing search cache");
// Extract the search service or return error if not available
let search_service = match &state.applications.search_service {
Some(service) => service,
@@ -154,11 +164,12 @@ impl SearchHandler {
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "Search service is not available"
}))
).into_response();
})),
)
.into_response();
}
};
// Clear the cache
match search_service.clear_search_cache().await {
Ok(_) => {
@@ -167,17 +178,19 @@ impl SearchHandler {
StatusCode::OK,
Json(json!({
"message": "Search cache cleared successfully"
}))
).into_response()
},
})),
)
.into_response()
}
Err(err) => {
error!("Error clearing search cache: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error clearing search cache: {}", err)
}))
).into_response()
})),
)
.into_response()
}
}
}
@@ -188,38 +201,38 @@ impl SearchHandler {
pub struct SearchParams {
/// Text to search for in file and folder names
pub query: Option<String>,
/// Filter by file types (comma-separated extensions)
#[serde(rename = "type")]
pub type_filter: Option<String>,
/// Filter items created after this date (timestamp)
pub created_after: Option<u64>,
/// Filter items created before this date (timestamp)
pub created_before: Option<u64>,
/// Filter items modified after this date (timestamp)
pub modified_after: Option<u64>,
/// Filter items modified before this date (timestamp)
pub modified_before: Option<u64>,
/// Minimum size in bytes
pub min_size: Option<u64>,
/// Maximum size in bytes
pub max_size: Option<u64>,
/// Folder ID to limit the search scope
pub folder_id: Option<String>,
/// Recursive search in subfolders
pub recursive: Option<bool>,
/// Result limit for pagination
pub limit: Option<usize>,
/// Offset for pagination
pub offset: Option<usize>,
}
}
+38 -18
View File
@@ -1,18 +1,18 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Deserialize;
use serde_json::json;
use crate::{
application::{
dtos::share_dto::{CreateShareDto, UpdateShareDto},
ports::share_ports::ShareUseCase
dtos::share_dto::{CreateShareDto, UpdateShareDto},
ports::share_ports::ShareUseCase,
},
common::errors::ErrorKind,
interfaces::middleware::auth::OptionalAuthUser,
@@ -35,7 +35,10 @@ pub async fn create_shared_link(
auth_user: OptionalAuthUser,
Json(dto): Json<CreateShareDto>,
) -> impl IntoResponse {
let user_id = auth_user.0.map(|u| u.id).unwrap_or_else(|| "anonymous".to_string());
let user_id = auth_user
.0
.map(|u| u.id)
.unwrap_or_else(|| "anonymous".to_string());
match share_use_case.create_shared_link(&user_id, dto).await {
Ok(share) => (StatusCode::CREATED, Json(share)).into_response(),
Err(err) => {
@@ -72,13 +75,23 @@ pub async fn get_user_shares(
auth_user: OptionalAuthUser,
Query(query): Query<GetSharesQuery>,
) -> impl IntoResponse {
let user_id = auth_user.0.map(|u| u.id).unwrap_or_else(|| "anonymous".to_string());
let user_id = auth_user
.0
.map(|u| u.id)
.unwrap_or_else(|| "anonymous".to_string());
let page = query.page.unwrap_or(1);
let per_page = query.per_page.unwrap_or(20);
match share_use_case.get_user_shared_links(&user_id, page, per_page).await {
match share_use_case
.get_user_shared_links(&user_id, page, per_page)
.await
{
Ok(shares) => (StatusCode::OK, Json(shares)).into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() }))).into_response()
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": err.to_string() })),
)
.into_response(),
}
}
@@ -127,7 +140,7 @@ pub async fn access_shared_item(
) -> impl IntoResponse {
// Register the access
let _ = share_use_case.register_shared_link_access(&token).await;
// Get the shared link
match share_use_case.get_shared_link_by_token(&token).await {
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
@@ -138,17 +151,21 @@ pub async fn access_shared_item(
if err.message.contains("expired") {
StatusCode::GONE // HTTP 410 Gone for expired links
} else if err.message.contains("password") {
return (StatusCode::UNAUTHORIZED, Json(json!({
"error": "Password required",
"requiresPassword": true
}))).into_response();
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Password required",
"requiresPassword": true
})),
)
.into_response();
} else {
StatusCode::FORBIDDEN
}
},
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": err.to_string() }))).into_response()
}
}
@@ -160,7 +177,10 @@ pub async fn verify_shared_item_password(
Path(token): Path<String>,
Json(req): Json<VerifyPasswordRequest>,
) -> impl IntoResponse {
match share_use_case.verify_shared_link_password(&token, &req.password).await {
match share_use_case
.verify_shared_link_password(&token, &req.password)
.await
{
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
Err(err) => {
let status = match err.kind {
@@ -173,10 +193,10 @@ pub async fn verify_shared_item_password(
} else {
StatusCode::FORBIDDEN
}
},
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": err.to_string() }))).into_response()
}
}
}
}
+208 -115
View File
@@ -1,8 +1,8 @@
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde_json::json;
use tracing::{debug, error, warn, instrument};
use tracing::{debug, error, instrument, warn};
// use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
@@ -20,28 +20,34 @@ pub async fn get_trash_items(
let effective_user = auth_user.id.clone();
debug!("Request to list trash items for user {}", effective_user);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
let result = trash_service.get_trash_items(&effective_user).await;
match result {
Ok(items) => {
debug!("Found {} items in trash", items.len());
(StatusCode::OK, Json(json!(items)))
},
}
Err(e) => {
error!("Error retrieving trash items: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error retrieving trash items: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error retrieving trash items: {}", e)
})),
)
}
}
}
@@ -53,33 +59,49 @@ pub async fn move_to_trash(
OptionalAuthUser(auth_user): OptionalAuthUser,
Path((item_type, item_id)): Path<(String, String)>,
) -> (StatusCode, Json<serde_json::Value>) {
let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous");
debug!("Request to move to trash: type={}, id={}, user={}",
item_type, item_id, user_id);
let user_id = auth_user
.as_ref()
.map(|u| u.id.as_str())
.unwrap_or("anonymous");
debug!(
"Request to move to trash: type={}, id={}, user={}",
item_type, item_id, user_id
);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
let result = trash_service.move_to_trash(&item_id, &item_type, user_id).await;
let result = trash_service
.move_to_trash(&item_id, &item_type, user_id)
.await;
match result {
Ok(_) => {
debug!("Item moved to trash successfully");
(StatusCode::OK, Json(json!({
"success": true,
"message": "Item moved to trash successfully"
})))
},
(
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item moved to trash successfully"
})),
)
}
Err(e) => {
error!("Error moving item to trash: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error moving item to trash: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error moving item to trash: {}", e)
})),
)
}
}
}
@@ -91,35 +113,49 @@ pub async fn move_file_to_trash(
OptionalAuthUser(auth_user): OptionalAuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous");
debug!("Request to move file to trash: id={}, user={}",
item_id, user_id);
let user_id = auth_user
.as_ref()
.map(|u| u.id.as_str())
.unwrap_or("anonymous");
debug!(
"Request to move file to trash: id={}, user={}",
item_id, user_id
);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
// Specify that it is a file
let result = trash_service.move_to_trash(&item_id, "file", user_id).await;
match result {
Ok(_) => {
debug!("File moved to trash successfully");
(StatusCode::OK, Json(json!({
"success": true,
"message": "File moved to trash successfully"
})))
},
(
StatusCode::OK,
Json(json!({
"success": true,
"message": "File moved to trash successfully"
})),
)
}
Err(e) => {
error!("Error moving file to trash: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error moving file to trash: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error moving file to trash: {}", e)
})),
)
}
}
}
@@ -131,35 +167,51 @@ pub async fn move_folder_to_trash(
OptionalAuthUser(auth_user): OptionalAuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous");
debug!("Request to move folder to trash: id={}, user={}",
item_id, user_id);
let user_id = auth_user
.as_ref()
.map(|u| u.id.as_str())
.unwrap_or("anonymous");
debug!(
"Request to move folder to trash: id={}, user={}",
item_id, user_id
);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
// Specify that it is a folder
let result = trash_service.move_to_trash(&item_id, "folder", user_id).await;
let result = trash_service
.move_to_trash(&item_id, "folder", user_id)
.await;
match result {
Ok(_) => {
debug!("Folder moved to trash successfully");
(StatusCode::OK, Json(json!({
"success": true,
"message": "Folder moved to trash successfully"
})))
},
(
StatusCode::OK,
Json(json!({
"success": true,
"message": "Folder moved to trash successfully"
})),
)
}
Err(e) => {
error!("Error moving folder to trash: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error moving folder to trash: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error moving folder to trash: {}", e)
})),
)
}
}
}
@@ -172,40 +224,55 @@ pub async fn restore_from_trash(
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
debug!("Request to restore item {} from trash", trash_id);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
let result = trash_service.restore_item(&trash_id, &auth_user.id).await;
match result {
Ok(_) => {
debug!("Item restored successfully");
(StatusCode::OK, Json(json!({
"success": true,
"message": "Item restored successfully"
})))
},
(
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item restored successfully"
})),
)
}
Err(e) => {
let err_str = format!("{}", e);
// If item not found, report success (it was already restored or removed)
if err_str.contains("not found") || err_str.contains("NotFound") {
warn!("Item not found in trash, but reporting success: {}", trash_id);
return (StatusCode::OK, Json(json!({
"success": true,
"message": "Item restored (or was already removed from trash)"
})));
warn!(
"Item not found in trash, but reporting success: {}",
trash_id
);
return (
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item restored (or was already removed from trash)"
})),
);
}
error!("Error restoring item from trash: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error restoring item from trash: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error restoring item from trash: {}", e)
})),
)
}
}
}
@@ -218,40 +285,57 @@ pub async fn delete_permanently(
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
debug!("Request to permanently delete item {}", trash_id);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
let result = trash_service.delete_permanently(&trash_id, &auth_user.id).await;
let result = trash_service
.delete_permanently(&trash_id, &auth_user.id)
.await;
match result {
Ok(_) => {
debug!("Item permanently deleted");
(StatusCode::OK, Json(json!({
"success": true,
"message": "Item deleted permanently"
})))
},
(
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item deleted permanently"
})),
)
}
Err(e) => {
let err_str = format!("{}", e);
// If item not found, report success (it was already deleted)
if err_str.contains("not found") || err_str.contains("NotFound") {
warn!("Item not found in trash, but reporting success: {}", trash_id);
return (StatusCode::OK, Json(json!({
"success": true,
"message": "Item deleted (or was already removed from trash)"
})));
warn!(
"Item not found in trash, but reporting success: {}",
trash_id
);
return (
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item deleted (or was already removed from trash)"
})),
);
}
error!("Error permanently deleting item: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error deleting item permanently: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error deleting item permanently: {}", e)
})),
)
}
}
}
@@ -263,30 +347,39 @@ pub async fn empty_trash(
auth_user: AuthUser,
) -> (StatusCode, Json<serde_json::Value>) {
debug!("Request to empty trash for user {}", auth_user.id);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (StatusCode::NOT_IMPLEMENTED, Json(json!({
"error": "Trash feature is not enabled"
})));
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "Trash feature is not enabled"
})),
);
}
};
let result = trash_service.empty_trash(&auth_user.id).await;
match result {
Ok(_) => {
debug!("Trash emptied successfully");
(StatusCode::OK, Json(json!({
"success": true,
"message": "Trash emptied successfully"
})))
},
(
StatusCode::OK,
Json(json!({
"success": true,
"message": "Trash emptied successfully"
})),
)
}
Err(e) => {
error!("Error emptying trash: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": format!("Error emptying trash: {}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Error emptying trash: {}", e)
})),
)
}
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,4 +2,4 @@ pub mod handlers;
pub mod routes;
pub use routes::create_api_routes;
pub use routes::create_public_api_routes;
pub use routes::create_public_api_routes;
+119 -73
View File
@@ -1,16 +1,13 @@
use std::sync::Arc;
use crate::common::di::AppState;
use axum::{
routing::{get, post, put, delete},
Router,
extract::DefaultBodyLimit,
response::Json as AxumJson,
routing::{delete, get, post, put},
};
use serde_json::json;
use tower_http::{
compression::CompressionLayer,
trace::TraceLayer,
};
use crate::common::di::AppState;
use std::sync::Arc;
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
/// Returns the application version from Cargo.toml (compile-time constant)
async fn get_version() -> AxumJson<serde_json::Value> {
@@ -24,15 +21,13 @@ use crate::interfaces::middleware::cache::{HttpCache, start_cache_cleanup_task};
use crate::application::services::batch_operations::BatchOperationService;
use crate::interfaces::api::handlers::folder_handler::FolderHandler;
use crate::interfaces::api::handlers::file_handler::FileHandler;
use crate::interfaces::api::handlers::i18n_handler::I18nHandler;
use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler;
use crate::interfaces::api::handlers::trash_handler;
use crate::interfaces::api::handlers::admin_handler;
use crate::interfaces::api::handlers::batch_handler::{
self, BatchHandlerState
};
use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState};
use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler;
use crate::interfaces::api::handlers::file_handler::FileHandler;
use crate::interfaces::api::handlers::folder_handler::FolderHandler;
use crate::interfaces::api::handlers::i18n_handler::I18nHandler;
use crate::interfaces::api::handlers::trash_handler;
/// Creates public API routes that should NOT require authentication.
///
@@ -49,12 +44,15 @@ pub fn create_public_api_routes(app_state: &AppState) -> Router<AppState> {
// Public share access routes — no auth required
if let Some(share_service) = share_service {
use crate::interfaces::api::handlers::share_handler;
let public_share_router = Router::new()
.route("/{token}", get(share_handler::access_shared_item))
.route("/{token}/verify", post(share_handler::verify_shared_item_password))
.route(
"/{token}/verify",
post(share_handler::verify_shared_item_password),
)
.with_state(share_service);
router = router.nest("/s", public_share_router);
}
@@ -63,9 +61,12 @@ pub fn create_public_api_routes(app_state: &AppState) -> Router<AppState> {
let i18n_router = Router::new()
.route("/locales", get(I18nHandler::get_locales))
.route("/translate", get(I18nHandler::translate))
.route("/locales/{locale_code}", get(I18nHandler::get_translations_by_locale))
.route(
"/locales/{locale_code}",
get(I18nHandler::get_translations_by_locale),
)
.with_state(i18n_service);
router = router.nest("/i18n", i18n_router);
}
@@ -95,49 +96,57 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
let batch_service = Arc::new(BatchOperationService::default(
file_retrieval_service.clone(),
file_management_service.clone(),
folder_service.clone()
folder_service.clone(),
));
// Create state for the batch operations handler
let batch_handler_state = BatchHandlerState {
batch_service: batch_service.clone(),
};
// Implement HTTP Cache
let http_cache = HttpCache::new();
// Define TTL values for different resource types (in seconds)
let _folders_ttl = 300; // 5 minutes
let _files_list_ttl = 300; // 5 minutes
let _i18n_ttl = 3600; // 1 hour
let _folders_ttl = 300; // 5 minutes
let _files_list_ttl = 300; // 5 minutes
let _i18n_ttl = 3600; // 1 hour
// Start the cleanup task for HTTP cache
start_cache_cleanup_task(http_cache.clone());
// Create the basic folders router with service operations
let folders_basic_router = Router::new()
.route("/", post(FolderHandler::create_folder))
.route("/", get(FolderHandler::list_root_folders))
.route("/paginated", get(FolderHandler::list_root_folders_paginated))
.route(
"/paginated",
get(FolderHandler::list_root_folders_paginated),
)
.route("/{id}", get(FolderHandler::get_folder))
.route("/{id}/contents", get(FolderHandler::list_folder_contents))
.route("/{id}/contents/paginated", get(FolderHandler::list_folder_contents_paginated))
.route(
"/{id}/contents/paginated",
get(FolderHandler::list_folder_contents_paginated),
)
.route("/{id}/rename", put(FolderHandler::rename_folder))
.route("/{id}/move", put(FolderHandler::move_folder))
.with_state(folder_service.clone());
// Special route for ZIP download that requires AppState instead of just FolderService
let folder_zip_router = Router::new()
.route("/{id}/download", get(FolderHandler::download_folder_zip))
.with_state(app_state.clone());
// Create folder operations that use trash (requires full AppState)
let folders_ops_router = Router::new()
.route("/{id}", delete(FolderHandler::delete_folder_with_trash));
let folders_ops_router =
Router::new().route("/{id}", delete(FolderHandler::delete_folder_with_trash));
// Merge the routers
let folders_router = folders_basic_router.merge(folders_ops_router).merge(folder_zip_router);
let folders_router = folders_basic_router
.merge(folders_ops_router)
.merge(folder_zip_router);
// Create file routes for basic operations and trash-enabled delete
let basic_file_router = Router::new()
.route("/", get(FileHandler::list_files_query))
@@ -146,16 +155,16 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
.route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10 GB for file uploads
.with_state(app_state.clone());
// File operations with trash support
let file_operations_router = Router::new()
.route("/{id}", delete(FileHandler::delete_file))
.route("/{id}/move", put(FileHandler::move_file_simple))
.route("/{id}/rename", put(FileHandler::rename_file));
// Merge the routers
let files_router = basic_file_router.merge(file_operations_router);
// Create routes for batch operations
let batch_router = Router::new()
// File operations
@@ -168,11 +177,11 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
.route("/folders/create", post(batch_handler::create_folders_batch))
.route("/folders/get", post(batch_handler::get_folders_batch))
.with_state(batch_handler_state);
// Create search routes if the service is available
let search_router = if search_service.is_some() {
use crate::interfaces::api::handlers::search_handler::SearchHandler;
Router::new()
// Simple search with query parameters
.route("/", get(SearchHandler::search_files_get))
@@ -184,13 +193,13 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
} else {
Router::new()
};
// Direct handler implementations for sharing, without depending on ShareHandler
// Create routes for shared resources management (requires auth)
let share_router = if let Some(share_service) = share_service.clone() {
use crate::interfaces::api::handlers::share_handler;
Router::new()
.route("/", post(share_handler::create_shared_link))
.route("/", get(share_handler::get_user_shares))
@@ -206,47 +215,86 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
// Create routes for favorites if the service is available
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
use crate::interfaces::api::handlers::favorites_handler;
Router::new()
.route("/", get(favorites_handler::get_favorites))
.route("/{item_type}/{item_id}", post(favorites_handler::add_favorite))
.route("/{item_type}/{item_id}", delete(favorites_handler::remove_favorite))
.route(
"/{item_type}/{item_id}",
post(favorites_handler::add_favorite),
)
.route(
"/{item_type}/{item_id}",
delete(favorites_handler::remove_favorite),
)
.with_state(favorites_service.clone())
} else {
Router::new()
};
// Create routes for recent items if the service is available
let recent_router = if let Some(recent_service) = recent_service.clone() {
use crate::interfaces::api::handlers::recent_handler;
Router::new()
.route("/", get(recent_handler::get_recent_items))
.route("/{item_type}/{item_id}", post(recent_handler::record_item_access))
.route("/{item_type}/{item_id}", delete(recent_handler::remove_from_recent))
.route(
"/{item_type}/{item_id}",
post(recent_handler::record_item_access),
)
.route(
"/{item_type}/{item_id}",
delete(recent_handler::remove_from_recent),
)
.route("/clear", delete(recent_handler::clear_recent_items))
.with_state(recent_service.clone())
} else {
Router::new()
};
// Create routes for chunked uploads (large files >10MB)
let chunked_upload_router = Router::new()
.route("/", post(ChunkedUploadHandler::create_upload))
.route("/{upload_id}", axum::routing::patch(ChunkedUploadHandler::upload_chunk))
.route("/{upload_id}", axum::routing::head(ChunkedUploadHandler::get_upload_status))
.route("/{upload_id}/complete", post(ChunkedUploadHandler::complete_upload))
.route(
"/{upload_id}",
axum::routing::patch(ChunkedUploadHandler::upload_chunk),
)
.route(
"/{upload_id}",
axum::routing::head(ChunkedUploadHandler::get_upload_status),
)
.route(
"/{upload_id}/complete",
post(ChunkedUploadHandler::complete_upload),
)
.route("/{upload_id}", delete(ChunkedUploadHandler::cancel_upload))
.with_state(Arc::new(app_state.clone()));
// Create routes for deduplication endpoints
let dedup_router = Router::new()
.route("/check/{hash}", get(super::handlers::dedup_handler::DedupHandler::check_hash))
.route("/upload", post(super::handlers::dedup_handler::DedupHandler::upload_with_dedup))
.route("/stats", get(super::handlers::dedup_handler::DedupHandler::get_stats))
.route("/blob/{hash}", get(super::handlers::dedup_handler::DedupHandler::get_blob))
.route("/blob/{hash}", delete(super::handlers::dedup_handler::DedupHandler::remove_reference))
.route("/recalculate", post(super::handlers::dedup_handler::DedupHandler::recalculate_stats))
.route(
"/check/{hash}",
get(super::handlers::dedup_handler::DedupHandler::check_hash),
)
.route(
"/upload",
post(super::handlers::dedup_handler::DedupHandler::upload_with_dedup),
)
.route(
"/stats",
get(super::handlers::dedup_handler::DedupHandler::get_stats),
)
.route(
"/blob/{hash}",
get(super::handlers::dedup_handler::DedupHandler::get_blob),
)
.route(
"/blob/{hash}",
delete(super::handlers::dedup_handler::DedupHandler::remove_reference),
)
.route(
"/recalculate",
post(super::handlers::dedup_handler::DedupHandler::recalculate_stats),
)
.with_state(app_state.clone());
let mut router = Router::new()
@@ -258,13 +306,12 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
.nest("/search", search_router)
.nest("/shares", share_router)
.nest("/favorites", favorites_router)
.nest("/recent", recent_router)
;
.nest("/recent", recent_router);
// Re-enable trash routes to make the trash view work
if let Some(_trash_service_ref) = trash_service.clone() {
tracing::info!("Setting up trash routes for trash view");
let trash_router = Router::new()
.route("/", get(trash_handler::get_trash_items))
.route("/files/{id}", delete(trash_handler::move_file_to_trash))
@@ -273,24 +320,23 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
.route("/{id}", delete(trash_handler::delete_permanently))
.route("/empty", delete(trash_handler::empty_trash))
.with_state(app_state.clone());
router = router.nest("/trash", trash_router);
} else {
tracing::warn!("Trash service not available - trash view will not work");
}
// NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs
// for client compatibility, NOT under /api.
// NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav)
// in main.rs for protocol compliance, NOT under /api.
// Admin settings routes (protected by admin_guard inside the handler)
let admin_router = admin_handler::admin_routes()
.with_state(app_state.clone());
let admin_router = admin_handler::admin_routes().with_state(app_state.clone());
router = router.nest("/admin", admin_router);
router
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
}
}