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:
File diff suppressed because it is too large
Load Diff
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms);
|
||||
&& let Ok(ranges) = parse_range_header(range_str)
|
||||
{
|
||||
let validated = ranges.validate(file_dto.size);
|
||||
if let Ok(valid_ranges) = validated {
|
||||
if let Some(range) = valid_ranges.first() {
|
||||
let start = *range.start();
|
||||
let end = *range.end();
|
||||
let range_length = end - start + 1;
|
||||
let disposition =
|
||||
Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms);
|
||||
|
||||
match retrieval.get_file_range_stream(&id, start, Some(end + 1)).await {
|
||||
Ok(stream) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, &file_dto.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, range_length)
|
||||
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_dto.size))
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
|
||||
.body(Body::from_stream(Box::into_pin(stream)))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error creating range stream: {}", err);
|
||||
// fall through to normal download
|
||||
}
|
||||
}
|
||||
match retrieval
|
||||
.get_file_range_stream(&id, start, Some(end + 1))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, &file_dto.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, range_length)
|
||||
.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, file_dto.size),
|
||||
)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::ETAG, &etag)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.body(Body::from_stream(Box::into_pin(stream)))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error creating range stream: {}", err);
|
||||
// fall through to normal download
|
||||
}
|
||||
} else {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normal download (delegated to service) ───────────────────
|
||||
let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms);
|
||||
|
||||
let accept_webp = headers.get(header::ACCEPT)
|
||||
let accept_webp = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|a| a.contains("image/webp"));
|
||||
let prefer_original = params.get("original").is_some_and(|v| v == "true" || v == "1");
|
||||
let prefer_original = params
|
||||
.get("original")
|
||||
.is_some_and(|v| v == "true" || v == "1");
|
||||
|
||||
match retrieval.get_file_optimized(&id, accept_webp, prefer_original).await {
|
||||
match retrieval
|
||||
.get_file_optimized(&id, accept_webp, prefer_original)
|
||||
.await
|
||||
{
|
||||
Ok((_file, content)) => match content {
|
||||
OptimizedFileContent::Bytes { data, mime_type, .. } => {
|
||||
Self::build_cached_response(
|
||||
data,
|
||||
&mime_type,
|
||||
&disposition,
|
||||
&etag,
|
||||
file_dto.size,
|
||||
¶ms,
|
||||
&*state.core.compression_service,
|
||||
).await
|
||||
.into_response()
|
||||
}
|
||||
OptimizedFileContent::Mmap(mmap_data) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &file_dto.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, mmap_data.len())
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from(mmap_data))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
OptimizedFileContent::Stream(pinned_stream) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &file_dto.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, file_dto.size)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(pinned_stream))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
OptimizedFileContent::Bytes {
|
||||
data, mime_type, ..
|
||||
} => Self::build_cached_response(
|
||||
data,
|
||||
&mime_type,
|
||||
&disposition,
|
||||
&etag,
|
||||
file_dto.size,
|
||||
¶ms,
|
||||
&*state.core.compression_service,
|
||||
)
|
||||
.await
|
||||
.into_response(),
|
||||
OptimizedFileContent::Mmap(mmap_data) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &file_dto.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, mmap_data.len())
|
||||
.header(header::ETAG, &etag)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from(mmap_data))
|
||||
.unwrap()
|
||||
.into_response(),
|
||||
OptimizedFileContent::Stream(pinned_stream) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &file_dto.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, file_dto.size)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(pinned_stream))
|
||||
.unwrap()
|
||||
.into_response(),
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Error downloading file: {}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": format!("Error reading file: {}", err)
|
||||
}))).into_response()
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Error reading file: {}", err)
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,9 +514,13 @@ impl FileHandler {
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error listing files: {}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": format!("Error listing files: {}", err)
|
||||
}))).into_response()
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Error listing files: {}", err)
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,39 +537,38 @@ impl FileHandler {
|
||||
let response = Self::upload_file_with_cache(State(state.clone()), multipart).await;
|
||||
|
||||
// Try to extract file info for thumbnail generation
|
||||
if let Ok(body_bytes) = axum::body::to_bytes(
|
||||
response.into_response().into_body(),
|
||||
10 * 1024,
|
||||
).await
|
||||
if let Ok(body_bytes) =
|
||||
axum::body::to_bytes(response.into_response().into_body(), 10 * 1024).await
|
||||
&& let Ok(file_info) = serde_json::from_slice::<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>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)>;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+134
-122
@@ -1,122 +1,134 @@
|
||||
//! HTTP/API Error types for the interfaces layer.
|
||||
//!
|
||||
//! This module contains error types specific to the HTTP/API layer.
|
||||
//! These errors handle the conversion from domain errors to HTTP responses.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::domain::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Error type for HTTP/API responses.
|
||||
///
|
||||
/// This struct represents errors that will be returned to HTTP clients.
|
||||
/// It contains the HTTP status code, a user-friendly message, and an error type identifier.
|
||||
#[derive(Debug)]
|
||||
pub struct AppError {
|
||||
pub status_code: StatusCode,
|
||||
pub message: String,
|
||||
pub error_type: String,
|
||||
}
|
||||
|
||||
/// JSON response structure for errors.
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
pub error_type: String,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
/// Create a new AppError with custom status code, message and error type.
|
||||
pub fn new(status_code: StatusCode, message: impl Into<String>, error_type: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status_code,
|
||||
message: message.into(),
|
||||
error_type: error_type.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a 400 Bad Request error.
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message, "BadRequest")
|
||||
}
|
||||
|
||||
/// Create a 401 Unauthorized error.
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, message, "Unauthorized")
|
||||
}
|
||||
|
||||
/// Create a 403 Forbidden error.
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, message, "Forbidden")
|
||||
}
|
||||
|
||||
/// Create a 404 Not Found error.
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, message, "NotFound")
|
||||
}
|
||||
|
||||
/// Create a 500 Internal Server Error.
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message, "InternalError")
|
||||
}
|
||||
|
||||
/// Create a 405 Method Not Allowed error.
|
||||
pub fn method_not_allowed(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::METHOD_NOT_ALLOWED, message, "MethodNotAllowed")
|
||||
}
|
||||
|
||||
/// Create a 409 Conflict error.
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message, "Conflict")
|
||||
}
|
||||
|
||||
/// Create a 415 Unsupported Media Type error.
|
||||
pub fn unsupported_media_type(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::UNSUPPORTED_MEDIA_TYPE, message, "UnsupportedMediaType")
|
||||
}
|
||||
|
||||
/// Create a 412 Precondition Failed error.
|
||||
pub fn precondition_failed(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::PRECONDITION_FAILED, message, "PreconditionFailed")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DomainError> for AppError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
let status_code = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
|
||||
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
|
||||
ErrorKind::AccessDenied => StatusCode::FORBIDDEN,
|
||||
ErrorKind::Timeout => StatusCode::REQUEST_TIMEOUT,
|
||||
ErrorKind::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorKind::NotImplemented => StatusCode::NOT_IMPLEMENTED,
|
||||
ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED,
|
||||
ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
Self {
|
||||
status_code,
|
||||
message: err.message,
|
||||
error_type: err.kind.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code;
|
||||
let error_response = ErrorResponse {
|
||||
status: status.to_string(),
|
||||
message: self.message,
|
||||
error_type: self.error_type,
|
||||
};
|
||||
|
||||
let body = Json(error_response);
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
//! HTTP/API Error types for the interfaces layer.
|
||||
//!
|
||||
//! This module contains error types specific to the HTTP/API layer.
|
||||
//! These errors handle the conversion from domain errors to HTTP responses.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::domain::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Error type for HTTP/API responses.
|
||||
///
|
||||
/// This struct represents errors that will be returned to HTTP clients.
|
||||
/// It contains the HTTP status code, a user-friendly message, and an error type identifier.
|
||||
#[derive(Debug)]
|
||||
pub struct AppError {
|
||||
pub status_code: StatusCode,
|
||||
pub message: String,
|
||||
pub error_type: String,
|
||||
}
|
||||
|
||||
/// JSON response structure for errors.
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
pub error_type: String,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
/// Create a new AppError with custom status code, message and error type.
|
||||
pub fn new(
|
||||
status_code: StatusCode,
|
||||
message: impl Into<String>,
|
||||
error_type: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
status_code,
|
||||
message: message.into(),
|
||||
error_type: error_type.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a 400 Bad Request error.
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message, "BadRequest")
|
||||
}
|
||||
|
||||
/// Create a 401 Unauthorized error.
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, message, "Unauthorized")
|
||||
}
|
||||
|
||||
/// Create a 403 Forbidden error.
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, message, "Forbidden")
|
||||
}
|
||||
|
||||
/// Create a 404 Not Found error.
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, message, "NotFound")
|
||||
}
|
||||
|
||||
/// Create a 500 Internal Server Error.
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message, "InternalError")
|
||||
}
|
||||
|
||||
/// Create a 405 Method Not Allowed error.
|
||||
pub fn method_not_allowed(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::METHOD_NOT_ALLOWED, message, "MethodNotAllowed")
|
||||
}
|
||||
|
||||
/// Create a 409 Conflict error.
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message, "Conflict")
|
||||
}
|
||||
|
||||
/// Create a 415 Unsupported Media Type error.
|
||||
pub fn unsupported_media_type(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
message,
|
||||
"UnsupportedMediaType",
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a 412 Precondition Failed error.
|
||||
pub fn precondition_failed(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
StatusCode::PRECONDITION_FAILED,
|
||||
message,
|
||||
"PreconditionFailed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DomainError> for AppError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
let status_code = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
|
||||
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
|
||||
ErrorKind::AccessDenied => StatusCode::FORBIDDEN,
|
||||
ErrorKind::Timeout => StatusCode::REQUEST_TIMEOUT,
|
||||
ErrorKind::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorKind::NotImplemented => StatusCode::NOT_IMPLEMENTED,
|
||||
ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED,
|
||||
ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
Self {
|
||||
status_code,
|
||||
message: err.message,
|
||||
error_type: err.kind.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code;
|
||||
let error_response = ErrorResponse {
|
||||
status: status.to_string(),
|
||||
message: self.message,
|
||||
error_type: self.error_type,
|
||||
};
|
||||
|
||||
let body = Json(error_response);
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
use std::convert::Infallible;
|
||||
use axum::{
|
||||
extract::{State, Request, FromRequestParts},
|
||||
http::{StatusCode, HeaderMap, header, request::Parts},
|
||||
extract::{FromRequestParts, Request, State},
|
||||
http::{HeaderMap, StatusCode, header, request::Parts},
|
||||
middleware::Next,
|
||||
response::{Response, IntoResponse},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
|
||||
@@ -77,7 +77,10 @@ where
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(OptionalUserId(
|
||||
parts.extensions.get::<CurrentUser>().map(|cu| cu.id.clone()),
|
||||
parts
|
||||
.extensions
|
||||
.get::<CurrentUser>()
|
||||
.map(|cu| cu.id.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -94,12 +97,12 @@ where
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(OptionalAuthUser(
|
||||
parts.extensions.get::<CurrentUser>().map(|cu| AuthUser {
|
||||
Ok(OptionalAuthUser(parts.extensions.get::<CurrentUser>().map(
|
||||
|cu| AuthUser {
|
||||
id: cu.id.clone(),
|
||||
username: cu.username.clone(),
|
||||
}),
|
||||
))
|
||||
},
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,19 +111,19 @@ where
|
||||
pub enum AuthError {
|
||||
#[error("Token not provided")]
|
||||
TokenNotProvided,
|
||||
|
||||
|
||||
#[error("Invalid token: {0}")]
|
||||
InvalidToken(String),
|
||||
|
||||
|
||||
#[error("Token expired")]
|
||||
TokenExpired,
|
||||
|
||||
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
|
||||
|
||||
#[error("Access denied: {0}")]
|
||||
AccessDenied(String),
|
||||
|
||||
|
||||
#[error("Authentication service unavailable")]
|
||||
AuthServiceUnavailable,
|
||||
}
|
||||
@@ -128,12 +131,17 @@ pub enum AuthError {
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self {
|
||||
AuthError::TokenNotProvided => (StatusCode::UNAUTHORIZED, "Token not provided".to_string()),
|
||||
AuthError::TokenNotProvided => {
|
||||
(StatusCode::UNAUTHORIZED, "Token not provided".to_string())
|
||||
}
|
||||
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
|
||||
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
|
||||
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
|
||||
AuthError::AuthServiceUnavailable => (StatusCode::INTERNAL_SERVER_ERROR, "Authentication service unavailable".to_string()),
|
||||
AuthError::AuthServiceUnavailable => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Authentication service unavailable".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = axum::Json(serde_json::json!({
|
||||
@@ -160,15 +168,15 @@ pub async fn auth_middleware(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or(AuthError::TokenNotProvided)?;
|
||||
|
||||
|
||||
// Validate that the token is not empty
|
||||
let token_str = token_str.trim();
|
||||
if token_str.is_empty() {
|
||||
return Err(AuthError::TokenNotProvided);
|
||||
}
|
||||
|
||||
|
||||
tracing::debug!("Processing authentication token");
|
||||
|
||||
|
||||
// Validate the token using the authentication service
|
||||
if let Some(auth_service) = state.auth_service.as_ref() {
|
||||
let token_service = &auth_service.token_service;
|
||||
@@ -183,14 +191,14 @@ pub async fn auth_middleware(
|
||||
};
|
||||
request.extensions_mut().insert(current_user);
|
||||
return Ok(next.run(request).await);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Token validation failed: {}", e);
|
||||
return Err(AuthError::InvalidToken(format!("Invalid token: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If no authentication service is available, deny access
|
||||
tracing::error!("Auth middleware invoked but auth service is not configured");
|
||||
Err(AuthError::AuthServiceUnavailable)
|
||||
@@ -200,22 +208,23 @@ pub async fn auth_middleware(
|
||||
///
|
||||
/// Must be applied AFTER auth_middleware, as it depends on
|
||||
/// `CurrentUser` being present in the request extensions.
|
||||
pub async fn require_admin(
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||
// Get the CurrentUser inserted by auth_middleware
|
||||
if let Some(current_user) = request.extensions().get::<CurrentUser>() {
|
||||
if current_user.role == "admin" {
|
||||
tracing::debug!("Admin access granted for user: {}", current_user.username);
|
||||
return next.run(request).await;
|
||||
}
|
||||
tracing::warn!("Admin access denied for user: {} (role: {})", current_user.username, current_user.role);
|
||||
tracing::warn!(
|
||||
"Admin access denied for user: {} (role: {})",
|
||||
current_user.username,
|
||||
current_user.role
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("Admin check failed: no authenticated user in request");
|
||||
}
|
||||
|
||||
|
||||
// Access denied
|
||||
let error = AuthError::AccessDenied("Admin role required".to_string());
|
||||
error.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
+178
-131
@@ -3,22 +3,22 @@ use axum::{
|
||||
http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode},
|
||||
middleware::Next,
|
||||
};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use tower::{Layer, Service};
|
||||
use std::task::{Context, Poll};
|
||||
use std::pin::Pin;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::future::Future;
|
||||
use bytes::Bytes;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tower::{Layer, Service};
|
||||
use tracing::{debug, info};
|
||||
|
||||
const MAX_CACHE_ENTRIES: usize = 1000; // Maximum number of cache entries
|
||||
const DEFAULT_MAX_AGE: u64 = 60; // Default time-to-live in seconds
|
||||
const MAX_CACHE_ENTRIES: usize = 1000; // Maximum number of cache entries
|
||||
const DEFAULT_MAX_AGE: u64 = 60; // Default time-to-live in seconds
|
||||
|
||||
// Type definitions for clarity
|
||||
type CacheKey = String;
|
||||
@@ -56,7 +56,7 @@ impl HttpCache {
|
||||
default_max_age: DEFAULT_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Creates a new instance with a specified time-to-live
|
||||
pub fn with_max_age(max_age: u64) -> Self {
|
||||
Self {
|
||||
@@ -64,65 +64,74 @@ impl HttpCache {
|
||||
default_max_age: max_age,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Gets cache statistics
|
||||
pub fn stats(&self) -> (usize, usize) {
|
||||
let lock = self.cache.lock().unwrap();
|
||||
let total = lock.len();
|
||||
|
||||
|
||||
// Count valid entries
|
||||
let _now = SystemTime::now();
|
||||
let valid = lock.values().filter(|entry| {
|
||||
match entry.timestamp.elapsed() {
|
||||
let valid = lock
|
||||
.values()
|
||||
.filter(|entry| match entry.timestamp.elapsed() {
|
||||
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
||||
Err(_) => false,
|
||||
}
|
||||
}).count();
|
||||
|
||||
})
|
||||
.count();
|
||||
|
||||
(total, valid)
|
||||
}
|
||||
|
||||
|
||||
/// Cleans up expired entries
|
||||
pub fn cleanup(&self) -> usize {
|
||||
let mut lock = self.cache.lock().unwrap();
|
||||
let initial_count = lock.len();
|
||||
|
||||
|
||||
// Remove expired entries
|
||||
let _now = SystemTime::now();
|
||||
lock.retain(|_, entry| {
|
||||
match entry.timestamp.elapsed() {
|
||||
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
||||
Err(_) => false,
|
||||
}
|
||||
lock.retain(|_, entry| match entry.timestamp.elapsed() {
|
||||
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
||||
Err(_) => false,
|
||||
});
|
||||
|
||||
|
||||
let removed = initial_count - lock.len();
|
||||
debug!("HttpCache cleanup: removed {} expired entries", removed);
|
||||
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
|
||||
/// Sets an entry in the cache
|
||||
fn set(&self, key: &str, etag: EntityTag, data: Option<Bytes>, headers: HeaderMap, max_age: Option<u64>) {
|
||||
fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
etag: EntityTag,
|
||||
data: Option<Bytes>,
|
||||
headers: HeaderMap,
|
||||
max_age: Option<u64>,
|
||||
) {
|
||||
let mut lock = self.cache.lock().unwrap();
|
||||
|
||||
|
||||
// Apply eviction policy if the cache is full
|
||||
if lock.len() >= MAX_CACHE_ENTRIES {
|
||||
debug!("Cache full, removing oldest entries");
|
||||
// Remove the oldest 10% of entries
|
||||
self.evict_oldest(&mut lock, MAX_CACHE_ENTRIES / 10);
|
||||
}
|
||||
|
||||
|
||||
// Store the new entry
|
||||
lock.insert(key.to_string(), CacheEntry {
|
||||
etag,
|
||||
data,
|
||||
headers,
|
||||
timestamp: SystemTime::now(),
|
||||
max_age: max_age.unwrap_or(self.default_max_age),
|
||||
});
|
||||
lock.insert(
|
||||
key.to_string(),
|
||||
CacheEntry {
|
||||
etag,
|
||||
data,
|
||||
headers,
|
||||
timestamp: SystemTime::now(),
|
||||
max_age: max_age.unwrap_or(self.default_max_age),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Removes the oldest entries from the cache
|
||||
fn evict_oldest(&self, cache: &mut HashMap<CacheKey, CacheEntry>, count: usize) {
|
||||
// Sort by timestamp
|
||||
@@ -130,20 +139,20 @@ impl HttpCache {
|
||||
.iter()
|
||||
.map(|(key, entry)| (key.clone(), entry.timestamp))
|
||||
.collect();
|
||||
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
entries.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
|
||||
|
||||
// Remove the oldest entries
|
||||
for (key, _) in entries.iter().take(count) {
|
||||
cache.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Gets an entry from the cache
|
||||
fn get(&self, key: &str) -> Option<CacheEntry> {
|
||||
let lock = self.cache.lock().unwrap();
|
||||
|
||||
|
||||
// Look up the entry
|
||||
if let Some(entry) = lock.get(key) {
|
||||
// Check if it has expired
|
||||
@@ -158,17 +167,17 @@ impl HttpCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
/// Generates a simple ETag for a block of bytes
|
||||
fn calculate_etag_for_bytes(&self, bytes: &[u8]) -> EntityTag {
|
||||
// Calculate hash
|
||||
let mut hasher = DefaultHasher::new();
|
||||
bytes.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
|
||||
|
||||
format!("\"{}\"", hash)
|
||||
}
|
||||
}
|
||||
@@ -180,80 +189,92 @@ pub async fn cache_middleware<T>(
|
||||
max_age: Option<u64>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response<Body>, (StatusCode, String)>
|
||||
where
|
||||
T: Serialize
|
||||
) -> Result<Response<Body>, (StatusCode, String)>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
// Only apply cache for GET requests
|
||||
if req.method() != Method::GET {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
|
||||
// Check if the response is cached
|
||||
let if_none_match = req.headers()
|
||||
let if_none_match = req
|
||||
.headers()
|
||||
.get("if-none-match")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
|
||||
// If there is a cache entry
|
||||
if let Some(cache_entry) = cache.get(cache_key) {
|
||||
// Check if the client already has the updated version
|
||||
if let Some(client_etag) = if_none_match
|
||||
&& client_etag == cache_entry.etag {
|
||||
// The client has the most recent version, send 304 Not Modified
|
||||
debug!("Cache hit (304) for key: {}", cache_key);
|
||||
return Ok(create_not_modified_response(&cache_entry));
|
||||
}
|
||||
|
||||
&& client_etag == cache_entry.etag
|
||||
{
|
||||
// The client has the most recent version, send 304 Not Modified
|
||||
debug!("Cache hit (304) for key: {}", cache_key);
|
||||
return Ok(create_not_modified_response(&cache_entry));
|
||||
}
|
||||
|
||||
// The client needs the updated version
|
||||
if let Some(data) = &cache_entry.data {
|
||||
debug!("Cache hit (200) for key: {}", cache_key);
|
||||
|
||||
|
||||
// Create response with cached data
|
||||
let mut response = Response::new(Body::from(data.clone()));
|
||||
|
||||
|
||||
// Copy original headers
|
||||
for (key, value) in &cache_entry.headers {
|
||||
if !key.as_str().eq_ignore_ascii_case("transfer-encoding") {
|
||||
response.headers_mut().insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add cache headers
|
||||
set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age));
|
||||
|
||||
set_cache_headers(
|
||||
&mut response,
|
||||
&cache_entry.etag,
|
||||
max_age.unwrap_or(cache_entry.max_age),
|
||||
);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Not cached or expired, continue with the middleware
|
||||
debug!("Cache miss for key: {}", cache_key);
|
||||
let response = next.run(req).await;
|
||||
|
||||
|
||||
// Don't cache errors
|
||||
if !response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
|
||||
// Convert the response to calculate the ETag
|
||||
let (parts, _body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10).await.unwrap_or_default();
|
||||
|
||||
let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Calculate ETag
|
||||
let etag = cache.calculate_etag_for_bytes(&bytes);
|
||||
|
||||
|
||||
// Save to cache
|
||||
cache.set(
|
||||
cache_key,
|
||||
etag.clone(),
|
||||
cache_key,
|
||||
etag.clone(),
|
||||
Some(bytes.clone()),
|
||||
parts.headers.clone(),
|
||||
max_age
|
||||
max_age,
|
||||
);
|
||||
|
||||
|
||||
// Create the response with ETag
|
||||
let mut response = Response::from_parts(parts, Body::from(bytes));
|
||||
set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache.default_max_age));
|
||||
|
||||
set_cache_headers(
|
||||
&mut response,
|
||||
&etag,
|
||||
max_age.unwrap_or(cache.default_max_age),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -263,18 +284,20 @@ fn create_not_modified_response(entry: &CacheEntry) -> Response<Body> {
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
|
||||
// Copy cache headers
|
||||
if let Some(cache_control) = entry.headers.get("cache-control") {
|
||||
response.headers_mut().insert("cache-control", cache_control.clone());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", cache_control.clone());
|
||||
}
|
||||
|
||||
|
||||
// Add ETag
|
||||
response.headers_mut().insert(
|
||||
"etag",
|
||||
HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static(""))
|
||||
"etag",
|
||||
HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static("")),
|
||||
);
|
||||
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
@@ -282,23 +305,23 @@ fn create_not_modified_response(entry: &CacheEntry) -> Response<Body> {
|
||||
fn set_cache_headers(response: &mut Response<Body>, etag: &str, max_age: u64) {
|
||||
// Add ETag
|
||||
response.headers_mut().insert(
|
||||
"etag",
|
||||
HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static(""))
|
||||
"etag",
|
||||
HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static("")),
|
||||
);
|
||||
|
||||
|
||||
// Configure Cache-Control
|
||||
let cache_control = format!("public, max-age={}", max_age);
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static(""))
|
||||
HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static("")),
|
||||
);
|
||||
|
||||
|
||||
// Add Last-Modified header
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
let last_modified = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
||||
response.headers_mut().insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static(""))
|
||||
HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static("")),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,7 +340,7 @@ impl HttpCacheLayer {
|
||||
max_age: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Sets the maximum time-to-live
|
||||
pub fn with_max_age(mut self, max_age: u64) -> Self {
|
||||
self.max_age = Some(max_age);
|
||||
@@ -327,7 +350,7 @@ impl HttpCacheLayer {
|
||||
|
||||
impl<S> Layer<S> for HttpCacheLayer {
|
||||
type Service = HttpCacheService<S>;
|
||||
|
||||
|
||||
fn layer(&self, service: S) -> Self::Service {
|
||||
HttpCacheService {
|
||||
inner: service,
|
||||
@@ -358,15 +381,15 @@ where
|
||||
type Response = Response<Body>;
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
// Generate cache key
|
||||
let cache_key = req.uri().path().to_string();
|
||||
|
||||
|
||||
// Only apply cache for GET requests
|
||||
if req.method() != Method::GET {
|
||||
let future = self.inner.call(req);
|
||||
@@ -375,41 +398,46 @@ where
|
||||
Ok(response_map_body(response).await)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Get client ETag
|
||||
let if_none_match = req.headers()
|
||||
let if_none_match = req
|
||||
.headers()
|
||||
.get("if-none-match")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
|
||||
// Check if there is a cache entry
|
||||
let cache_clone = self.cache.clone();
|
||||
let max_age = self.max_age;
|
||||
let entry = cache_clone.get(&cache_key);
|
||||
|
||||
|
||||
match entry {
|
||||
Some(cache_entry) if if_none_match == Some(&cache_entry.etag) => {
|
||||
// The client has the correct version, send 304
|
||||
debug!("Cache HIT (304): {}", cache_key);
|
||||
let response = create_not_modified_response(&cache_entry);
|
||||
Box::pin(async move { Ok(response) })
|
||||
},
|
||||
}
|
||||
Some(cache_entry) if cache_entry.data.is_some() => {
|
||||
// The client needs the updated version
|
||||
debug!("Cache HIT (200): {}", cache_key);
|
||||
let mut response = Response::new(Body::from(cache_entry.data.clone().unwrap()));
|
||||
|
||||
|
||||
// Copy original headers
|
||||
for (key, value) in &cache_entry.headers {
|
||||
if !key.as_str().eq_ignore_ascii_case("transfer-encoding") {
|
||||
response.headers_mut().insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add cache headers
|
||||
set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age));
|
||||
|
||||
set_cache_headers(
|
||||
&mut response,
|
||||
&cache_entry.etag,
|
||||
max_age.unwrap_or(cache_entry.max_age),
|
||||
);
|
||||
|
||||
Box::pin(async move { Ok(response) })
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
// Not cached or expired
|
||||
debug!("Cache MISS: {}", cache_key);
|
||||
@@ -417,36 +445,40 @@ where
|
||||
let cache_clone = self.cache.clone();
|
||||
let max_age = self.max_age;
|
||||
let cache_key = cache_key.clone();
|
||||
|
||||
|
||||
Box::pin(async move {
|
||||
let response = future.await.map_err(|e| e.into())?;
|
||||
let response = response_map_body(response).await;
|
||||
|
||||
|
||||
// Don't cache errors
|
||||
if !response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
|
||||
// Get the body and calculate ETag
|
||||
let (parts, body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(body, 1024 * 1024 * 10).await?;
|
||||
|
||||
|
||||
// Calculate ETag
|
||||
let etag = cache_clone.calculate_etag_for_bytes(&bytes);
|
||||
|
||||
|
||||
// Save to cache
|
||||
cache_clone.set(
|
||||
&cache_key,
|
||||
etag.clone(),
|
||||
&cache_key,
|
||||
etag.clone(),
|
||||
Some(bytes.clone()),
|
||||
parts.headers.clone(),
|
||||
max_age
|
||||
max_age,
|
||||
);
|
||||
|
||||
|
||||
// Create the response with ETag
|
||||
let mut response = Response::from_parts(parts, Body::from(bytes));
|
||||
set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache_clone.default_max_age));
|
||||
|
||||
set_cache_headers(
|
||||
&mut response,
|
||||
&etag,
|
||||
max_age.unwrap_or(cache_clone.default_max_age),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
@@ -481,13 +513,16 @@ where
|
||||
pub fn start_cache_cleanup_task(cache: HttpCache) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300)); // Every 5 minutes
|
||||
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let removed = cache.cleanup();
|
||||
let (total, valid) = cache.stats();
|
||||
|
||||
info!("HTTP Cache cleanup: removed {}, current: {}/{}", removed, valid, total);
|
||||
|
||||
info!(
|
||||
"HTTP Cache cleanup: removed {}, current: {}/{}",
|
||||
removed, valid, total
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -496,49 +531,61 @@ pub fn start_cache_cleanup_task(cache: HttpCache) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Hash)]
|
||||
struct TestData {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_etag_generation() {
|
||||
let cache = HttpCache::new();
|
||||
|
||||
let data1 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap();
|
||||
let data2 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap();
|
||||
let data3 = serde_json::to_vec(&TestData { id: 2, name: "Test".to_string() }).unwrap();
|
||||
|
||||
|
||||
let data1 = serde_json::to_vec(&TestData {
|
||||
id: 1,
|
||||
name: "Test".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let data2 = serde_json::to_vec(&TestData {
|
||||
id: 1,
|
||||
name: "Test".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let data3 = serde_json::to_vec(&TestData {
|
||||
id: 2,
|
||||
name: "Test".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let etag1 = cache.calculate_etag_for_bytes(&data1);
|
||||
let etag2 = cache.calculate_etag_for_bytes(&data2);
|
||||
let etag3 = cache.calculate_etag_for_bytes(&data3);
|
||||
|
||||
|
||||
// Same data should generate the same ETag
|
||||
assert_eq!(etag1, etag2);
|
||||
|
||||
|
||||
// Different data should generate different ETags
|
||||
assert_ne!(etag1, etag3);
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_hit_miss() {
|
||||
let cache = HttpCache::new();
|
||||
|
||||
|
||||
// Create test data directly as Bytes
|
||||
let bytes1 = Bytes::from(r#"{"id":1,"name":"Test"}"#);
|
||||
let headers1 = HeaderMap::new();
|
||||
|
||||
|
||||
let etag1 = cache.calculate_etag_for_bytes(&bytes1);
|
||||
cache.set("test", etag1.clone(), Some(bytes1.clone()), headers1, None);
|
||||
|
||||
|
||||
// Verify cache hit
|
||||
let entry = cache.get("test").unwrap();
|
||||
assert_eq!(entry.etag, etag1);
|
||||
assert_eq!(entry.data.unwrap(), bytes1);
|
||||
|
||||
|
||||
// Verify cache miss
|
||||
assert!(cache.get("nonexistent").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod cache;
|
||||
pub mod auth;
|
||||
pub mod redirect; // Add redirect middleware for API to Axum transition
|
||||
pub mod cache;
|
||||
pub mod redirect; // Add redirect middleware for API to Axum transition
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
use std::task::{Context, Poll};
|
||||
use axum::http::{Uri, uri::PathAndQuery};
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use axum::{
|
||||
extract::Request,
|
||||
response::Response,
|
||||
middleware::Next,
|
||||
};
|
||||
use axum::http::{uri::PathAndQuery, Uri};
|
||||
use std::task::{Context, Poll};
|
||||
use tower::{Layer, Service};
|
||||
|
||||
/// A middleware that redirects specific paths to the proper Axum routes.
|
||||
@@ -15,7 +11,7 @@ pub struct RedirectMiddleware<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> Service<Request> for RedirectMiddleware<S>
|
||||
impl<S> Service<Request> for RedirectMiddleware<S>
|
||||
where
|
||||
S: Service<Request, Response = Response> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
@@ -33,7 +29,7 @@ where
|
||||
// Log the incoming request
|
||||
let uri = request.uri().clone();
|
||||
let path = uri.path().to_string();
|
||||
|
||||
|
||||
// Check and potentially redirect file-related API routes
|
||||
if path.starts_with("/api/files") {
|
||||
// Handle file-related redirects
|
||||
@@ -44,23 +40,28 @@ where
|
||||
// File download request - let's adjust the URI to match the Axum route
|
||||
// Extract the ID from the path
|
||||
let file_id = &path[11..];
|
||||
tracing::info!("Redirecting file download request: {} to /api/files/{}", path, file_id);
|
||||
|
||||
tracing::info!(
|
||||
"Redirecting file download request: {} to /api/files/{}",
|
||||
path,
|
||||
file_id
|
||||
);
|
||||
|
||||
// Create a new URI for the Axum route
|
||||
let uri_clone = uri.clone();
|
||||
let mut parts = uri_clone.into_parts();
|
||||
let query = parts.path_and_query
|
||||
let query = parts
|
||||
.path_and_query
|
||||
.as_ref()
|
||||
.and_then(|pq| pq.query())
|
||||
.map(|q| format!("?{}", q))
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
let new_path = format!("/api/files/{}{}", file_id, query);
|
||||
parts.path_and_query = Some(
|
||||
PathAndQuery::from_maybe_shared(new_path.into_bytes())
|
||||
.expect("Failed to create path and query")
|
||||
.expect("Failed to create path and query"),
|
||||
);
|
||||
|
||||
|
||||
let new_uri = Uri::from_parts(parts).expect("Failed to create URI");
|
||||
*request.uri_mut() = new_uri;
|
||||
}
|
||||
@@ -69,10 +70,10 @@ where
|
||||
tracing::debug!("Folder request detected: {}", path);
|
||||
// We might need to add specific redirects for folder operations here
|
||||
}
|
||||
|
||||
|
||||
// Pass the request to the inner service
|
||||
let future = self.inner.call(request);
|
||||
|
||||
|
||||
Box::pin(async move {
|
||||
let response = future.await?;
|
||||
Ok(response)
|
||||
@@ -93,27 +94,27 @@ impl<S> Layer<S> for RedirectLayer {
|
||||
}
|
||||
|
||||
/// Axum middleware function that can be applied directly to routes
|
||||
pub async fn redirect_middleware(
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
pub async fn redirect_middleware(request: Request, next: Next) -> Response {
|
||||
// Get the path
|
||||
let path = request.uri().path().to_string();
|
||||
|
||||
|
||||
// Process the request based on the path
|
||||
if path.starts_with("/api/files") || path.starts_with("/api/folders") || path.starts_with("/api/auth") {
|
||||
if path.starts_with("/api/files")
|
||||
|| path.starts_with("/api/folders")
|
||||
|| path.starts_with("/api/auth")
|
||||
{
|
||||
tracing::debug!("API request detected in middleware: {}", path);
|
||||
// Log additional information about the request
|
||||
if let Some(content_type) = request.headers().get("content-type") {
|
||||
tracing::debug!("Content-Type: {:?}", content_type);
|
||||
}
|
||||
|
||||
|
||||
// For debugging auth-related requests
|
||||
if path.starts_with("/api/auth") {
|
||||
tracing::info!("Auth API request: {} method: {}", path, request.method());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Continue the middleware chain
|
||||
next.run(request).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod api;
|
||||
pub mod web;
|
||||
pub mod middleware;
|
||||
pub mod errors;
|
||||
pub mod middleware;
|
||||
pub mod web;
|
||||
|
||||
pub use api::create_api_routes;
|
||||
pub use api::create_public_api_routes;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use axum::{
|
||||
routing::get,
|
||||
Router,
|
||||
response::Html,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::di::AppState;
|
||||
use axum::{Router, response::Html, routing::get};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
/// Creates web routes for serving static files
|
||||
pub fn create_web_routes() -> Router<AppState> {
|
||||
@@ -20,9 +16,7 @@ pub fn create_web_routes() -> Router<AppState> {
|
||||
.route("/admin", get(serve_admin_page))
|
||||
.route("/shared", get(serve_shared_page))
|
||||
// Serve static files
|
||||
.fallback_service(
|
||||
ServeDir::new(static_path)
|
||||
)
|
||||
.fallback_service(ServeDir::new(static_path))
|
||||
}
|
||||
|
||||
/// Serve the login page
|
||||
@@ -43,4 +37,4 @@ async fn serve_admin_page() -> Html<&'static str> {
|
||||
/// Serve the shared page
|
||||
async fn serve_shared_page() -> Html<&'static str> {
|
||||
Html(include_str!("../../../static/shared.html"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user