Files
Oxicloud/src/interfaces/api/handlers/file_handler.rs
T

949 lines
39 KiB
Rust
Raw Normal View History

2025-03-17 21:28:08 +01:00
use axum::{
Json,
2026-02-14 01:29:34 +01:00
body::Body,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
2025-03-17 21:28:08 +01:00
};
2026-02-03 17:59:04 +01:00
use bytes::Bytes;
2026-02-14 01:29:34 +01:00
use http_range_header::parse_range_header;
2025-03-17 21:28:08 +01:00
use serde::Deserialize;
2025-03-19 00:44:27 +01:00
use std::collections::HashMap;
2025-03-17 21:28:08 +01:00
2026-02-14 01:29:34 +01:00
use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort};
use crate::application::ports::file_ports::OptimizedFileContent;
2025-03-26 18:33:22 +01:00
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::{AuthUser, OptionalUserId};
2025-03-17 21:28:08 +01:00
2025-03-26 19:08:07 +01:00
/**
* Type aliases for dependency injection state.
*/
/// Global application state for dependency injection
2025-03-26 18:33:22 +01:00
type GlobalState = AppState;
2025-03-17 21:28:08 +01:00
2025-03-26 19:08:07 +01:00
/**
* API handler for file-related operations.
2026-02-14 01:29:34 +01:00
*
* 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.
2025-03-26 19:08:07 +01:00
*/
2025-03-17 21:28:08 +01:00
pub struct FileHandler;
impl FileHandler {
// ═══════════════════════════════════════════════════════════════════════
// UPLOAD
// ═══════════════════════════════════════════════════════════════════════
2026-02-03 17:59:04 +01:00
/// Uploads a file with TRUE STREAMING support and Write-Behind Cache
2026-02-14 01:29:34 +01:00
///
/// 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.
2025-03-17 21:28:08 +01:00
pub async fn upload_file(
State(state): State<GlobalState>,
2025-03-17 21:28:08 +01:00
mut multipart: Multipart,
) -> impl IntoResponse {
2026-02-03 17:59:04 +01:00
let mut folder_id: Option<String> = None;
2026-02-03 17:59:04 +01:00
tracing::debug!("📤 Processing file upload request");
2025-03-17 21:28:08 +01:00
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
2026-02-03 17:59:04 +01:00
if name == "folder_id" {
let v = field.text().await.unwrap_or_default();
2026-02-14 01:29:34 +01:00
if !v.is_empty() {
folder_id = Some(v);
}
2026-02-03 17:59:04 +01:00
continue;
}
2025-03-17 21:28:08 +01:00
if name == "file" {
2025-03-23 22:44:18 +01:00
let filename = field.file_name().unwrap_or("unnamed").to_string();
2026-02-14 01:29:34 +01:00
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// Collect chunks from multipart
2026-02-03 17:59:04 +01:00
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);
}
// Empty file
2026-02-03 17:59:04 +01:00
if chunks.is_empty() {
let upload_service = &state.applications.file_upload_service;
2026-02-14 01:29:34 +01:00
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(),
};
2026-02-03 17:59:04 +01:00
}
// 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);
2026-02-14 01:29:34 +01:00
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);
2026-02-03 17:59:04 +01:00
}
Err(err) => {
tracing::error!("❌ UPLOAD FAILED: {} - {}", filename, err);
return Self::domain_error_response(err);
2026-02-03 17:59:04 +01:00
}
2025-03-17 21:28:08 +01:00
}
}
}
2026-02-14 01:29:34 +01:00
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file provided"
})),
)
.into_response()
2026-02-03 17:59:04 +01:00
}
/// 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.
2026-02-03 17:59:04 +01:00
pub async fn upload_file_with_cache(
State(state): State<GlobalState>,
auth_user: AuthUser,
2026-02-03 17:59:04 +01:00
mut multipart: Multipart,
) -> impl IntoResponse {
let upload_service = &state.applications.file_upload_service;
2026-02-03 17:59:04 +01:00
let mut folder_id: Option<String> = None;
tracing::debug!("📤 Processing file upload request (with smart upload)");
2026-02-03 17:59:04 +01:00
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
2026-02-03 17:59:04 +01:00
if name == "folder_id" {
let v = field.text().await.unwrap_or_default();
2026-02-14 01:29:34 +01:00
if !v.is_empty() {
folder_id = Some(v);
}
2026-02-03 17:59:04 +01:00
continue;
}
2026-02-03 17:59:04 +01:00
if name == "file" {
let filename = field.file_name().unwrap_or("unnamed").to_string();
2026-02-14 01:29:34 +01:00
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
2026-02-03 17:59:04 +01:00
// Collect 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);
}
// Empty file
2026-02-03 17:59:04 +01:00
if chunks.is_empty() {
let upload_svc = &state.applications.file_upload_service;
2026-02-14 01:29:34 +01:00
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(),
2026-02-03 17:59:04 +01:00
};
}
// ── Quota enforcement ────────────────────────────────────
if let Some(storage_svc) = state.storage_usage_service.as_ref() {
if let Err(err) = storage_svc
.check_storage_quota(&auth_user.id, total_size as u64)
.await
{
tracing::warn!(
"⛔ UPLOAD REJECTED (quota): user={}, file={}, size={} — {}",
auth_user.username,
filename,
total_size,
err
);
return Self::quota_error_response(err).into_response();
}
}
// Delegate to smart_upload (handles write-behind, dedup, streaming)
match upload_service
2026-02-14 01:29:34 +01:00
.smart_upload(
filename.clone(),
folder_id,
content_type,
chunks,
total_size,
)
.await
{
Ok((file, strategy)) => {
tracing::info!(
"✅ SMART UPLOAD: {} ({} bytes, strategy: {:?}, ID: {})",
2026-02-14 01:29:34 +01:00
filename,
total_size,
strategy,
file.id
);
return Self::created_json_response(&file).into_response();
2026-02-03 17:59:04 +01:00
}
Err(err) => {
tracing::error!("❌ SMART UPLOAD FAILED: {} - {}", filename, err);
return Self::domain_error_response(err).into_response();
2026-02-03 17:59:04 +01:00
}
2025-03-17 21:28:08 +01:00
}
}
}
2026-02-14 01:29:34 +01:00
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file provided"
})),
)
.into_response()
2026-02-03 17:59:04 +01:00
}
// ═══════════════════════════════════════════════════════════════════════
// 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.
2026-02-03 17:59:04 +01:00
pub async fn get_thumbnail(
State(state): State<GlobalState>,
Path((id, size)): Path<(String, String)>,
2025-03-17 21:28:08 +01:00
) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize;
let file_retrieval_service = &state.applications.file_retrieval_service;
2026-02-03 17:59:04 +01:00
let thumbnail_service = &state.core.thumbnail_service;
2026-02-03 17:59:04 +01:00
let thumb_size = match size.as_str() {
"icon" => ThumbnailSize::Icon,
"preview" => ThumbnailSize::Preview,
"large" => ThumbnailSize::Large,
_ => {
2026-02-14 01:29:34 +01:00
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid thumbnail size. Use: icon, preview, or large"
})),
)
.into_response();
2026-02-03 17:59:04 +01:00
}
2025-03-19 00:44:27 +01:00
};
let file = match file_retrieval_service.get_file(&id).await {
2026-02-03 17:59:04 +01:00
Ok(f) => f,
Err(err) => {
2026-02-14 01:29:34 +01:00
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("File not found: {}", err)
})),
)
.into_response();
2026-02-03 17:59:04 +01:00
}
};
if !thumbnail_service.is_supported_image(&file.mime_type) {
2026-02-14 01:29:34 +01:00
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "File is not a supported image type"
})),
)
.into_response();
2026-02-03 17:59:04 +01:00
}
2026-02-03 17:59:04 +01:00
let storage_root = state.core.path_service.get_root_path();
let file_path = storage_root.join(&file.path);
2026-02-14 01:29:34 +01:00
match thumbnail_service
.get_thumbnail(&id, thumb_size, &file_path)
.await
{
2026-02-03 17:59:04 +01:00
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()
}
2026-02-03 17:59:04 +01:00
Err(err) => {
tracing::error!("Thumbnail generation failed: {}", err);
2026-02-14 01:29:34 +01:00
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to generate thumbnail: {}", err)
})),
)
.into_response()
2026-02-03 17:59:04 +01:00
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// 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.
2026-02-03 17:59:04 +01:00
pub async fn download_file(
State(state): State<GlobalState>,
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> impl IntoResponse {
let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata ────────────────────────────────────────
let file_dto = match retrieval.get_file(&id).await {
2026-02-03 17:59:04 +01:00
Ok(f) => f,
Err(err) => {
2026-02-14 01:29:34 +01:00
let status = if err.to_string().contains("not found")
|| err.to_string().contains("NotFound")
{
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
2025-03-19 00:44:27 +01:00
};
2026-02-14 01:29:34 +01:00
return (
status,
Json(serde_json::json!({
"error": err.to_string()
})),
)
.into_response();
2026-02-03 17:59:04 +01:00
}
};
// ── Metadata-only request ────────────────────────────────────
2026-02-14 01:29:34 +01:00
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);
// ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
2026-02-14 01:29:34 +01:00
&& (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()
2026-02-14 01:29:34 +01:00
&& 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
2026-02-03 17:59:04 +01:00
}
}
}
2026-02-14 01:29:34 +01:00
} 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);
2026-02-14 01:29:34 +01:00
let accept_webp = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("image/webp"));
2026-02-14 01:29:34 +01:00
let prefer_original = params
.get("original")
.is_some_and(|v| v == "true" || v == "1");
2026-02-14 01:29:34 +01:00
match retrieval
.get_file_optimized(&id, accept_webp, prefer_original)
.await
{
Ok((_file, content)) => match content {
2026-02-14 01:29:34 +01:00
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);
2026-02-14 01:29:34 +01:00
(
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<GlobalState>,
Query(params): Query<HashMap<String, String>>,
) -> 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);
2026-02-14 01:29:34 +01:00
(
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<GlobalState>,
auth_user: AuthUser,
multipart: Multipart,
) -> impl IntoResponse {
// Use the smart upload handler
let response =
Self::upload_file_with_cache(State(state.clone()), auth_user, multipart).await;
// Try to extract file info for thumbnail generation
2026-02-14 01:29:34 +01:00
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)
2026-02-14 01:29:34 +01:00
&& 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);
});
}
2026-02-14 01:29:34 +01:00
// 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<GlobalState>,
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());
2026-02-03 17:59:04 +01:00
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()))
2026-02-03 17:59:04 +01:00
.unwrap()
}
2025-03-19 00:44:27 +01:00
Err(err) => {
tracing::error!("Error listing files: {}", err);
2026-02-14 01:29:34 +01:00
(
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`.
///
/// When auth is available, uses trash-first deletion; otherwise falls back
/// to permanent delete so the endpoint works with or without auth.
pub async fn delete_file(
State(state): State<GlobalState>,
OptionalUserId(user_id): OptionalUserId,
Path(id): Path<String>,
) -> impl IntoResponse {
let mgmt = &state.applications.file_management_service;
let result = if let Some(uid) = user_id {
// Auth available: trash-first with dedup cleanup
2026-02-14 01:29:34 +01:00
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);
mgmt.delete_file(&id).await.map(|_| {
tracing::info!("File permanently deleted (no auth): {}", id);
})
};
match result {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => {
tracing::error!("Error deleting file: {}", err);
2026-02-14 01:29:34 +01:00
let status = if err.to_string().contains("not found")
|| err.to_string().contains("NotFound")
{
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
2026-02-14 01:29:34 +01:00
(
status,
Json(serde_json::json!({
"error": format!("Error deleting file: {}", err)
})),
)
.into_response()
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// MOVE
// ═══════════════════════════════════════════════════════════════════════
2026-02-08 22:44:42 +01:00
/// Renames a file
pub async fn rename_file(
State(state): State<GlobalState>,
Path(id): Path<String>,
Json(payload): Json<serde_json::Value>,
) -> impl IntoResponse {
let new_name = match payload.get("name").and_then(|v| v.as_str()) {
Some(name) if !name.trim().is_empty() => name.trim().to_string(),
_ => {
2026-02-14 01:29:34 +01:00
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Missing or empty 'name' field"
})),
)
.into_response();
2026-02-08 22:44:42 +01:00
}
};
tracing::info!("Renaming file {} to \"{}\"", id, new_name);
let mgmt = &state.applications.file_management_service;
match mgmt.rename_file(&id, &new_name).await {
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => {
tracing::error!("Error renaming file: {}", err);
2026-02-14 01:29:34 +01:00
let status = if err.to_string().contains("not found")
|| err.to_string().contains("NotFound")
{
2026-02-08 22:44:42 +01:00
StatusCode::NOT_FOUND
} else if err.to_string().contains("already exists") {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
2026-02-14 01:29:34 +01:00
(
status,
Json(serde_json::json!({
"error": format!("Error renaming file: {}", err)
})),
)
.into_response()
2026-02-08 22:44:42 +01:00
}
}
}
/// Moves a file to a different folder
pub async fn move_file(
State(state): State<GlobalState>,
Path(id): Path<String>,
Json(payload): Json<MoveFilePayload>,
) -> 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 {
2026-02-14 01:29:34 +01:00
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)
2026-02-14 01:29:34 +01:00
})),
)
.into_response()
2026-02-03 17:59:04 +01:00
}
2026-02-14 01:29:34 +01:00
},
Err(err) => {
tracing::error!("File not found for move: {}", err);
2026-02-14 01:29:34 +01:00
(
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<GlobalState>,
Path(id): Path<String>,
Json(payload): Json<serde_json::Value>,
) -> 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);
2026-02-14 01:29:34 +01:00
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Error moving file: {}", err)
})),
)
.into_response()
}
2025-03-17 21:28:08 +01:00
}
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE HELPERS
// ═══════════════════════════════════════════════════════════════════════
/// Combine chunks into a single Vec<u8>.
fn combine_chunks(chunks: Vec<Bytes>, total_size: usize) -> 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
}
}
/// Build a Content-Disposition header value.
fn content_disposition(name: &str, mime: &str, params: &HashMap<String, String>) -> String {
2026-02-14 01:29:34 +01:00
let force_inline = params
.get("inline")
.is_some_and(|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<Body> {
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<Body> {
let status = match err.kind {
crate::common::errors::ErrorKind::NotFound => StatusCode::NOT_FOUND,
crate::common::errors::ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
_ => 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 a quota-specific error response with 507 status and structured body.
fn quota_error_response(err: crate::common::errors::DomainError) -> Response<Body> {
Response::builder()
.status(StatusCode::INSUFFICIENT_STORAGE)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"error": err.message,
"error_type": "QuotaExceeded"
})
.to_string(),
))
.unwrap()
}
/// Build response for cached/small files with optional compression.
2026-02-03 17:59:04 +01:00
async fn build_cached_response(
content: Bytes,
mime_type: &str,
disposition: &str,
etag: &str,
file_size: u64,
params: &HashMap<String, String>,
compression_service: &dyn CompressionPort,
2026-02-03 17:59:04 +01:00
) -> Response<Body> {
let compression_param = params.get("compress").map(|v| v.as_str());
let force_compress = compression_param == Some("true") || compression_param == Some("1");
2026-02-14 01:29:34 +01:00
let force_no_compress =
compression_param == Some("false") || compression_param == Some("0");
2026-02-03 17:59:04 +01:00
let should_compress = if force_no_compress {
false
} else if force_compress {
true
} else {
compression_service.should_compress(mime_type, file_size)
};
2026-02-03 17:59:04 +01:00
let compression_level = match params.get("compression_level").map(|v| v.as_str()) {
Some("fast") => CompressionLevel::Fast,
Some("best") => CompressionLevel::Best,
_ => CompressionLevel::Default,
};
2026-02-03 17:59:04 +01:00
let builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::ETAG, etag)
2026-02-14 01:29:34 +01:00
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
2026-02-03 17:59:04 +01:00
.header(header::VARY, "Accept-Encoding");
2026-02-03 17:59:04 +01:00
if should_compress {
2026-02-14 01:29:34 +01:00
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(),
2026-02-03 17:59:04 +01:00
}
} else {
builder
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, content.len())
.body(Body::from(content))
.unwrap()
}
}
2025-03-17 21:28:08 +01:00
}
/// Payload for moving a file
#[derive(Debug, Deserialize)]
pub struct MoveFilePayload {
/// Target folder ID (None means root)
pub folder_id: Option<String>,
2026-02-14 01:29:34 +01:00
}