use axum::{ 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 utoipa::ToSchema; use crate::application::dtos::file_dto::FileDto; use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::common::di::AppState; use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; /** * Type aliases for dependency injection state. */ /// Global application state for dependency injection type GlobalState = Arc; /** * 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 { // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── // utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion. // Rust allows struct definitions at module scope but forbids them inside impl blocks, // so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP // verb or annotation content. All route handlers are free functions below. // TODO: collapse after utoipa upgrade. // ═══════════════════════════════════════════════════════════════════════ // UPLOAD // ═══════════════════════════════════════════════════════════════════════ /// Streaming file upload — constant ~64 KB RAM regardless of file size. /// /// **Hash-on-Write**: BLAKE3 is computed while spooling the multipart /// body to the temp file. This eliminates the second sequential read /// that dedup_service would otherwise need, cutting total I/O in half. pub async fn upload_file( State(state): State, auth_user: AuthUser, multipart: Multipart, ) -> impl IntoResponse { match Self::upload_file_inner(&state, &auth_user, multipart).await { Ok((file, _blob_hash)) => Self::created_json_response(&file).into_response(), Err(response) => response.into_response(), } } /// Core upload logic shared by [`Self::upload_file`] and /// [`Self::upload_file_with_thumbnails`]. /// /// Returns `(FileDto, blob_hash)` on success. The blob hash is the /// BLAKE3 digest computed during the hash-on-write spool and is /// propagated without an extra database round-trip so that callers /// (e.g. thumbnail generation) can resolve the physical blob path /// immediately. async fn upload_file_inner( state: &GlobalState, auth_user: &AuthUser, mut multipart: Multipart, ) -> Result<(crate::application::dtos::file_dto::FileDto, String), Response> { let upload_service = &state.applications.file_upload_service; let mut folder_id: Option = None; tracing::debug!("📤 Processing streaming file upload (hash-on-write)"); 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 raw_filename = field.file_name().unwrap_or("unnamed").to_string(); // Browsers send the full relative path (e.g. "Screenshots/file.png") // as the filename for folder uploads via webkitRelativePath. // Strip path components to get the basename only. // This also prevents path-traversal attacks. let filename = raw_filename .rsplit('/') .next() .unwrap_or(&raw_filename) .rsplit('\\') .next() .unwrap_or(&raw_filename) .to_string(); let content_type = field .content_type() .unwrap_or("application/octet-stream") .to_string(); // ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ── if let Some(ref fid) = folder_id { use crate::application::ports::inbound::FolderUseCase; let folder_service = &state.applications.folder_service; if folder_service .get_folder_owned(fid, auth_user.id) .await .is_err() { tracing::warn!( "⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user", auth_user.username, fid, ); return Err(Self::domain_error_response( crate::common::errors::DomainError::not_found("Folder", fid), )); } } // ── Early quota check (before spooling to disk) ────── if let Some(storage_svc) = state.storage_usage_service.as_ref() { let estimated_size = field .headers() .get(header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .unwrap_or(0); if let Err(err) = storage_svc .check_storage_quota(auth_user.id, estimated_size) .await { tracing::warn!( "⛔ UPLOAD REJECTED (early quota): user={}, file={}, est_size={}", auth_user.username, filename, estimated_size ); return Err(Self::quota_error_response(err)); } } // ── Spool multipart field to temp file + hash-on-write ── // .dedup_temp is created once by DedupService::initialize() at startup let temp_dir = state.core.path_service.get_root_path().join(".dedup_temp"); let temp_path = temp_dir.join(format!("upload-{}", uuid::Uuid::new_v4())); let mut total_size: u64 = 0; let mut hasher = blake3::Hasher::new(); let spool_result: Result<(), String> = async { let file = tokio::fs::File::create(&temp_path) .await .map_err(|e| format!("Failed to create temp file: {}", e))?; // Pre-allocate if Content-Length is known (reduces fragmentation) let hint = field .headers() .get(axum::http::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()); if let Some(len) = hint { let _ = file.set_len(len).await; // best-effort } // 512 KB buffer — 8× fewer write syscalls than 64 KB let mut writer = tokio::io::BufWriter::with_capacity(524_288, file); let mut field = field; // IMPORTANT: use explicit match instead of `while let Ok(Some(..))`. // The old pattern silently swallowed Err (client disconnect) // and accepted partially received data as a complete upload. loop { match field.chunk().await { Ok(Some(chunk)) => { total_size += chunk.len() as u64; hasher.update(&chunk); tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk) .await .map_err(|e| format!("Failed to write chunk: {}", e))?; } Ok(None) => break, // End of field — upload complete Err(e) => { return Err(format!( "Connection lost during upload (received {} bytes): {}", total_size, e )); } } } tokio::io::AsyncWriteExt::flush(&mut writer) .await .map_err(|e| format!("Failed to flush temp file: {}", e))?; Ok(()) } .await; if let Err(e) = spool_result { let _ = tokio::fs::remove_file(&temp_path).await; tracing::error!("❌ UPLOAD SPOOL FAILED: {} - {}", filename, e); return Err(Self::domain_error_response( crate::common::errors::DomainError::internal_error("FileUpload", e), )); } // Empty file — use streaming path with the (empty) temp file if total_size == 0 { let hash = hasher.finalize().to_hex().to_string(); let dto = upload_service .upload_file_streaming( filename, folder_id, content_type, &temp_path, 0, Some(hash.clone()), ) .await .map_err(Self::domain_error_response)?; return Ok((dto, hash)); } // Finalize hash let hash = hasher.finalize().to_hex().to_string(); // ── MIME detection (magic bytes + extension fallback) ─ let content_type = crate::common::mime_detect::refine_content_type_from_file( &temp_path, &filename, &content_type, ) .await; // ── Quota enforcement ──────────────────────────────── if let Some(storage_svc) = state.storage_usage_service.as_ref() && let Err(err) = storage_svc .check_storage_quota(auth_user.id, total_size) .await { let _ = tokio::fs::remove_file(&temp_path).await; tracing::warn!( "⛔ UPLOAD REJECTED (quota): user={}, file={}, size={}", auth_user.username, filename, total_size ); return Err(Self::quota_error_response(err)); } // ── Streaming upload (temp file → blob store, hash pre-computed) ─ match upload_service .upload_file_streaming( filename.clone(), folder_id, content_type, &temp_path, total_size, Some(hash.clone()), ) .await { Ok(file) => { tracing::info!( "✅ STREAMING UPLOAD: {} ({} bytes, ID: {})", filename, total_size, file.id ); return Ok((file, hash)); } Err(err) => { let _ = tokio::fs::remove_file(&temp_path).await; tracing::error!("❌ UPLOAD FAILED: {} - {}", filename, err); return Err(Self::domain_error_response(err)); } } } } Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No file provided" })), ) .into_response()) } // ═══════════════════════════════════════════════════════════════════════ // THUMBNAILS // ═══════════════════════════════════════════════════════════════════════ /// Get a thumbnail for a file (image or video). /// /// **Cache-first**: if the thumbnail already exists in the moka in-memory /// cache or on disk, serve it immediately — **zero DB queries**. The /// ownership check was already performed when the thumbnail was first /// generated (at upload) or uploaded (PUT by the owner). UUIDv4 file IDs /// have 122 bits of entropy, making enumeration infeasible. /// /// **ETag / 304**: responses carry an immutable ETag. If the browser /// sends `If-None-Match` matching the ETag, we return 304 Not Modified /// without touching cache or DB — pure header round-trip. /// /// The DB path is only taken on a **cache miss for images** where the /// thumbnail hasn't been generated yet (first access after upload if /// background generation hasn't finished). pub(super) async fn get_thumbnail_impl( State(state): State, auth_user: AuthUser, headers: HeaderMap, Path((id, size)): Path<(String, String)>, ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; 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(); } }; // ── ETag short-circuit (Solution C) ────────────────────────── // Thumbnails are immutable — the ETag never changes for a given // (file_id, size) pair. If the browser already has it, return 304 // with zero I/O or DB work. let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size); if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) && let Ok(val) = if_none_match.to_str() && (val == etag || val == "*") { return Response::builder() .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .body(Body::empty()) .unwrap() .into_response(); } // ── Cache-first path (Solution A) ──────────────────────────── // Try moka (RAM) → disk before touching the database. // If the thumbnail exists it was authorized at creation time. if let Some(data) = thumbnail_service .get_cached_thumbnail(&id, None, thumb_size.into()) .await { return Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/jpeg") .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(); } // ── Cache miss — need DB for ownership + blob resolution ───── let file_retrieval_service = &state.applications.file_retrieval_service; let file = match file_retrieval_service .get_file_owned(&id, auth_user.id) .await { Ok(f) => f, Err(err) => { return AppError::from(err).into_response(); } }; // Non-image (video, etc.) with no cached thumbnail → 204 if !thumbnail_service.is_supported_image(&file.mime_type) { return Response::builder() .status(StatusCode::NO_CONTENT) .header(header::CACHE_CONTROL, "no-store") .body(Body::empty()) .unwrap() .into_response(); } // Resolve the blob hash (content-addressable storage). let blob_hash = match state .repositories .file_read_repository .get_blob_hash(&id) .await { Ok(hash) => hash, Err(_) => { return AppError::internal_error("File blob not found").into_response(); } }; if let Some(data) = thumbnail_service .get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into()) .await { return Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/jpeg") .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(); } let original_bytes = match state.core.dedup_service.read_blob_bytes(&blob_hash).await { Ok(bytes) => bytes, Err(err) => { return AppError::internal_error(format!( "Failed to load source image for thumbnail generation: {}", err )) .into_response(); } }; match thumbnail_service .get_thumbnail_from_bytes(&id, &blob_hash, thumb_size.into(), original_bytes) .await { Ok(data) => Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/jpeg") .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) => AppError::internal_error(format!("Thumbnail generation failed: {}", err)) .into_response(), } } // ═══════════════════════════════════════════════════════════════════════ // UPLOAD THUMBNAIL (client-generated, e.g. video frames) // ═══════════════════════════════════════════════════════════════════════ /// Accept a client-generated thumbnail (e.g. video frame extracted via /// `