use axum::{ extract::{Path, State, Multipart, Query}, http::{StatusCode, header, HeaderMap, Response}, response::IntoResponse, body::Body, Json, }; use bytes::Bytes; 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::file_ports::OptimizedFileContent; use crate::common::di::AppState; use crate::interfaces::middleware::auth::CurrentUserId; /** * Type aliases for dependency injection state. */ /// Global application state for dependency injection 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. */ pub struct FileHandler; impl FileHandler { // ═══════════════════════════════════════════════════════════════════════ // UPLOAD // ═══════════════════════════════════════════════════════════════════════ /// 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. pub async fn upload_file( State(state): State, mut multipart: Multipart, ) -> impl IntoResponse { let mut folder_id: Option = None; tracing::debug!("📤 Processing file upload request"); while let Some(field) = multipart.next_field().await.unwrap_or(None) { let name = field.name().unwrap_or("").to_string(); if name == "folder_id" { let v = field.text().await.unwrap_or_default(); 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(); // Collect chunks from multipart let mut chunks: Vec = 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); } // 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 { Ok(file) => Self::created_json_response(&file).into_response(), Err(err) => Self::domain_error_response(err).into_response(), }; } // 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 { Ok(file) => { tracing::info!("✅ UPLOAD COMPLETE: {} (ID: {})", filename, file.id); return Self::created_json_response(&file); } Err(err) => { tracing::error!("❌ UPLOAD FAILED: {} - {}", filename, err); return Self::domain_error_response(err); } } } } (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file provided" }))).into_response() } /// Uploads a file with Write-Behind Cache + Dedup (smart strategy). /// /// Delegates entirely to `FileUploadUseCase::smart_upload` which picks the /// optimal tier and handles deduplication internally. pub async fn upload_file_with_cache( State(state): State, mut multipart: Multipart, ) -> impl IntoResponse { let upload_service = &state.applications.file_upload_service; let mut folder_id: Option = None; tracing::debug!("📤 Processing file upload request (with smart upload)"); while let Some(field) = multipart.next_field().await.unwrap_or(None) { let name = field.name().unwrap_or("").to_string(); if name == "folder_id" { let v = field.text().await.unwrap_or_default(); 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(); // Collect chunks let mut chunks: Vec = 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); } // 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 { Ok(file) => Self::created_json_response(&file).into_response(), Err(err) => Self::domain_error_response(err).into_response(), }; } // Delegate to smart_upload (handles write-behind, dedup, streaming) match upload_service .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 ); return Self::created_json_response(&file).into_response(); } Err(err) => { tracing::error!("❌ SMART UPLOAD FAILED: {} - {}", filename, err); return Self::domain_error_response(err).into_response(); } } } } (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file provided" }))).into_response() } // ═══════════════════════════════════════════════════════════════════════ // THUMBNAILS // ═══════════════════════════════════════════════════════════════════════ /// Get a thumbnail for an image file. /// /// Thumbnail orchestration (path resolution, generation, caching) stays here /// because it is tightly coupled to HTTP response headers. pub async fn get_thumbnail( State(state): State, Path((id, size)): Path<(String, String)>, ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; let file_retrieval_service = &state.applications.file_retrieval_service; let thumbnail_service = &state.core.thumbnail_service; let thumb_size = match size.as_str() { "icon" => ThumbnailSize::Icon, "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(); } }; 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(); } }; 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(); } 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 { Ok(data) => { let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/webp") .header(header::CONTENT_LENGTH, data.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header(header::ETAG, etag) .body(Body::from(data)) .unwrap() .into_response() } 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() } } } // ═══════════════════════════════════════════════════════════════════════ // DOWNLOAD // ═══════════════════════════════════════════════════════════════════════ /// Downloads a file with optimized multi-tier strategy. /// /// The tier selection (write-behind → hot cache → WebP transcode → mmap → /// streaming) is fully handled by `FileRetrievalUseCase::get_file_optimized`. /// This handler only deals with HTTP concerns: ETag, Range, Content-Disposition, /// and optional compression. pub async fn download_file( State(state): State, Path(id): Path, Query(params): Query>, headers: HeaderMap, ) -> impl IntoResponse { let retrieval = &state.applications.file_retrieval_service; // ── Get file metadata ──────────────────────────────────────── 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") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; return (status, Json(serde_json::json!({ "error": err.to_string() }))).into_response(); } }; let etag = format!("\"{}-{}\"", id, file_dto.modified_at); // ── ETag (304 Not Modified) ────────────────────────────────── if let Some(inm) = headers.get(header::IF_NONE_MATCH) { if let Ok(client_etag) = inm.to_str() { if 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) { if let Ok(range_str) = range_header.to_str() { if let Ok(ranges) = parse_range_header(range_str) { let validated = ranges.validate(file_dto.size); if let Ok(valid_ranges) = validated { if let Some(range) = valid_ranges.first() { let start = *range.start(); let end = *range.end(); let range_length = end - start + 1; let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); match retrieval.get_file_range_stream(&id, start, Some(end + 1)).await { Ok(stream) => { return Response::builder() .status(StatusCode::PARTIAL_CONTENT) .header(header::CONTENT_TYPE, &file_dto.mime_type) .header(header::CONTENT_DISPOSITION, &disposition) .header(header::CONTENT_LENGTH, range_length) .header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_dto.size)) .header(header::ACCEPT_RANGES, "bytes") .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") .body(Body::from_stream(Box::into_pin(stream))) .unwrap() .into_response(); } Err(err) => { tracing::error!("Error creating range stream: {}", err); // fall through to normal download } } } } else { return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size)) .body(Body::empty()) .unwrap() .into_response(); } } } } // ── Normal download (delegated to service) ─────────────────── let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); let accept_webp = headers.get(header::ACCEPT) .and_then(|v| v.to_str().ok()) .map_or(false, |a| a.contains("image/webp")); let prefer_original = params.get("original").map_or(false, |v| v == "true" || v == "1"); match retrieval.get_file_optimized(&id, accept_webp, prefer_original).await { Ok((_file, content)) => match content { OptimizedFileContent::Bytes { data, mime_type, .. } => { Self::build_cached_response( data, &mime_type, &disposition, &etag, file_dto.size, ¶ms, &*state.core.compression_service, ).await .into_response() } OptimizedFileContent::Mmap(mmap_data) => { Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &file_dto.mime_type) .header(header::CONTENT_DISPOSITION, &disposition) .header(header::CONTENT_LENGTH, mmap_data.len()) .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") .header(header::ACCEPT_RANGES, "bytes") .body(Body::from(mmap_data)) .unwrap() .into_response() } OptimizedFileContent::Stream(pinned_stream) => { Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &file_dto.mime_type) .header(header::CONTENT_DISPOSITION, &disposition) .header(header::CONTENT_LENGTH, file_dto.size) .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") .header(header::ACCEPT_RANGES, "bytes") .body(Body::from_stream(pinned_stream)) .unwrap() .into_response() } }, Err(err) => { tracing::error!("Error downloading file: {}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Error reading file: {}", err) }))).into_response() } } } // ═══════════════════════════════════════════════════════════════════════ // LIST // ═══════════════════════════════════════════════════════════════════════ /// Lists files, extracting `folder_id` from query parameters. /// /// Axum-compatible handler wrapper around [`Self::list_files`]. pub async fn list_files_query( State(state): State, Query(params): Query>, ) -> impl IntoResponse { let folder_id = params.get("folder_id").map(|id| id.as_str()); tracing::info!("API: Listing files with folder_id: {:?}", folder_id); let retrieval = &state.applications.file_retrieval_service; match retrieval.list_files(folder_id).await { Ok(files) => { tracing::info!("Found {} files", files.len()); (StatusCode::OK, Json(files)).into_response() } Err(err) => { tracing::error!("Error listing files: {}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Error listing files: {}", err) }))).into_response() } } } /// Uploads a file and generates thumbnails in the background for images. /// /// Delegates to [`Self::upload_file_with_cache`] and, on success, spawns /// a background task to generate all thumbnail sizes. pub async fn upload_file_with_thumbnails( State(state): State, multipart: Multipart, ) -> impl IntoResponse { // Use the smart upload handler 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(file_info) = serde_json::from_slice::(&body_bytes) { if 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); }); } // 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() } /// Lists files, optionally filtered by folder ID pub async fn list_files( State(state): State, folder_id: Option<&str>, ) -> impl IntoResponse { tracing::info!("Listing files with folder_id: {:?}", folder_id); let retrieval = &state.applications.file_retrieval_service; match retrieval.list_files(folder_id).await { Ok(files) => { tracing::info!("Found {} files through the service", files.len()); Response::builder() .status(StatusCode::OK) .header("Cache-Control", "no-cache, no-store, must-revalidate") .header("Pragma", "no-cache") .header("Expires", "0") .body(Body::from(serde_json::to_string(&files).unwrap())) .unwrap() } Err(err) => { tracing::error!("Error listing files: {}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": err.to_string() }))).into_response() } } } // ═══════════════════════════════════════════════════════════════════════ // DELETE // ═══════════════════════════════════════════════════════════════════════ /// Deletes a file (trash-first with dedup cleanup). /// /// All logic (trash fallback, dedup ref-count, hash computation) is handled /// by `FileManagementUseCase::delete_with_cleanup`. pub async fn delete_file( State(state): State, CurrentUserId(user_id): CurrentUserId, Path(id): Path, ) -> impl IntoResponse { let mgmt = &state.applications.file_management_service; match mgmt.delete_with_cleanup(&id, &user_id).await { Ok(was_trashed) => { if was_trashed { tracing::info!("File moved to trash: {}", id); } else { tracing::info!("File permanently deleted: {}", id); } 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") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; (status, Json(serde_json::json!({ "error": format!("Error deleting file: {}", err) }))).into_response() } } } // ═══════════════════════════════════════════════════════════════════════ // MOVE // ═══════════════════════════════════════════════════════════════════════ /// Moves a file to a different folder pub async fn move_file( State(state): State, Path(id): Path, Json(payload): Json, ) -> impl IntoResponse { tracing::info!("Moving file {} to folder {:?}", id, payload.folder_id); let retrieval = &state.applications.file_retrieval_service; 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!({ "error": format!("Error moving file: {}", err) }))).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() } } } /// Moves a file to a different folder (simplified payload accepting generic JSON) pub async fn move_file_simple( State(state): State, Path(id): Path, Json(payload): Json, ) -> impl IntoResponse { let folder_id = payload .get("folder_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let mgmt = &state.applications.file_management_service; match mgmt.move_file(&id, folder_id).await { 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() } } } // ═══════════════════════════════════════════════════════════════════════ // PRIVATE HELPERS // ═══════════════════════════════════════════════════════════════════════ /// Combine chunks into a single Vec. fn combine_chunks(chunks: Vec, total_size: usize) -> Vec { 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 } } /// Build a Content-Disposition header value. fn content_disposition(name: &str, mime: &str, params: &HashMap) -> String { let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1"); if force_inline || mime.starts_with("image/") || mime == "application/pdf" || mime.starts_with("video/") || mime.starts_with("audio/") { format!("inline; filename=\"{}\"", name) } else { format!("attachment; filename=\"{}\"", name) } } /// Build a 201 Created JSON response. fn created_json_response(file: &crate::application::dtos::file_dto::FileDto) -> Response { Response::builder() .status(StatusCode::CREATED) .header(header::CONTENT_TYPE, "application/json") .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate") .body(Body::from(serde_json::to_string(file).unwrap())) .unwrap() } /// Build error response for DomainError. fn domain_error_response(err: crate::common::errors::DomainError) -> Response { let status = match err.kind { crate::common::errors::ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; Response::builder() .status(status) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( serde_json::json!({ "error": format!("Error: {}", err) }).to_string(), )) .unwrap() } /// Build response for cached/small files with optional compression. async fn build_cached_response( content: Bytes, mime_type: &str, disposition: &str, etag: &str, file_size: u64, params: &HashMap, compression_service: &dyn CompressionPort, ) -> Response { 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 should_compress = if force_no_compress { false } else if force_compress { true } else { compression_service.should_compress(mime_type, file_size) }; let compression_level = match params.get("compression_level").map(|v| v.as_str()) { Some("fast") => CompressionLevel::Fast, Some("best") => CompressionLevel::Best, _ => CompressionLevel::Default, }; let builder = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_DISPOSITION, disposition) .header(header::ETAG, etag) .header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate") .header(header::VARY, "Accept-Encoding"); if should_compress { match compression_service.compress_data(&content.to_vec(), 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 .header(header::CONTENT_TYPE, mime_type) .header(header::CONTENT_LENGTH, content.len()) .body(Body::from(content)) .unwrap() } } } /// Payload for moving a file #[derive(Debug, Deserialize)] pub struct MoveFilePayload { /// Target folder ID (None means root) pub folder_id: Option, }