use axum::{ Json, extract::{Multipart, Path, State}, http::{StatusCode, header}, response::IntoResponse, }; use pinakes_core::{model::MediaId, upload}; use tokio_util::io::ReaderStream; use uuid::Uuid; use crate::{ dto::{ManagedStorageStatsResponse, UploadResponse}, error::{ApiError, ApiResult}, state::AppState, }; /// Sanitize a filename for use in Content-Disposition headers. /// Strips characters that could break header parsing or enable injection. fn sanitize_content_disposition(filename: &str) -> String { let safe: String = filename .chars() .map(|c| { if c == '"' || c == '\\' || c == '\n' || c == '\r' { '_' } else { c } }) .collect(); format!("attachment; filename=\"{safe}\"") } /// Upload a file to managed storage /// POST /api/upload pub async fn upload_file( State(state): State, mut multipart: Multipart, ) -> ApiResult> { let managed_storage = state .managed_storage .as_ref() .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; let config = state.config.read().await; if !config.managed_storage.enabled { return Err(ApiError::bad_request("Managed storage is not enabled")); } drop(config); // Extract file from multipart let field = multipart .next_field() .await .map_err(|e| { ApiError::bad_request(format!("Failed to read multipart field: {e}")) })? .ok_or_else(|| ApiError::bad_request("No file provided"))?; let original_filename = field .file_name() .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string); let content_type = field.content_type().map_or_else( || "application/octet-stream".to_string(), std::string::ToString::to_string, ); let data = field.bytes().await.map_err(|e| { ApiError::bad_request(format!("Failed to read file data: {e}")) })?; // Process the upload let result = upload::process_upload_bytes( &state.storage, managed_storage.as_ref(), &data, &original_filename, Some(&content_type), ) .await .map_err(|e| ApiError::internal(format!("Upload failed: {e}")))?; Ok(Json(result.into())) } /// Download a managed file /// GET /api/media/{id}/download pub async fn download_file( State(state): State, Path(id): Path, ) -> ApiResult { let media_id = MediaId(id); let item = state .storage .get_media(media_id) .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; let managed_storage = state .managed_storage .as_ref() .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; // Check if this is a managed file if item.storage_mode != pinakes_core::model::StorageMode::Managed { // For external files, stream from their original path let file = tokio::fs::File::open(&item.path) .await .map_err(|e| ApiError::not_found(format!("File not found: {e}")))?; let stream = ReaderStream::new(file); let body = axum::body::Body::from_stream(stream); let content_type = item.media_type.mime_type(); let filename = item.original_filename.unwrap_or(item.file_name); return Ok(( [ (header::CONTENT_TYPE, content_type), ( header::CONTENT_DISPOSITION, sanitize_content_disposition(&filename), ), ], body, )); } // For managed files, stream from content-addressable storage let file = managed_storage .open(&item.content_hash) .await .map_err(|e| ApiError::not_found(format!("Blob not found: {e}")))?; let stream = ReaderStream::new(file); let body = axum::body::Body::from_stream(stream); let content_type = item.media_type.mime_type(); let filename = item.original_filename.unwrap_or(item.file_name); Ok(( [ (header::CONTENT_TYPE, content_type), ( header::CONTENT_DISPOSITION, sanitize_content_disposition(&filename), ), ], body, )) } /// Migrate an external file to managed storage /// POST /api/media/{id}/move-to-managed pub async fn move_to_managed( State(state): State, Path(id): Path, ) -> ApiResult { let managed_storage = state .managed_storage .as_ref() .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; let media_id = MediaId(id); upload::migrate_to_managed( &state.storage, managed_storage.as_ref(), media_id, ) .await .map_err(|e| ApiError::internal(format!("Migration failed: {e}")))?; Ok(StatusCode::NO_CONTENT) } /// Get managed storage statistics /// GET /api/managed/stats pub async fn managed_stats( State(state): State, ) -> ApiResult> { let stats = state .storage .managed_storage_stats() .await .map_err(|e| ApiError::internal(format!("Failed to get stats: {e}")))?; Ok(Json(stats.into())) }