style: apply cargo fmt to entire codebase

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

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