5c53d94c0c
- Add ?metadata=true support to GET /api/files/{id} to return JSON metadata
instead of binary content (was the root cause of favorites not loading)
- Fix favorites loadFileDetails to use metadata endpoint with auth headers
- Add star icon on favorited files/folders in grid view (top-left corner)
- Add star icon on favorited files/folders in list view (next to name)
- Refresh file view when toggling favorites so star appears/disappears
- Add CSS styles for .favorite-star and .favorite-star-inline
785 lines
35 KiB
Rust
785 lines
35 KiB
Rust
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::OptionalUserId;
|
||
|
||
/**
|
||
* 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<GlobalState>,
|
||
mut multipart: Multipart,
|
||
) -> impl IntoResponse {
|
||
let mut folder_id: Option<String> = 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<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
|
||
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<GlobalState>,
|
||
mut multipart: Multipart,
|
||
) -> impl IntoResponse {
|
||
let upload_service = &state.applications.file_upload_service;
|
||
let mut folder_id: Option<String> = 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<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
|
||
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<GlobalState>,
|
||
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<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 {
|
||
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();
|
||
}
|
||
};
|
||
|
||
// ── Metadata-only request ────────────────────────────────────
|
||
if params.get("metadata").map_or(false, |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) {
|
||
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<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);
|
||
(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>,
|
||
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::<serde_json::Value>(&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<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());
|
||
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`.
|
||
///
|
||
/// 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
|
||
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);
|
||
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
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/// 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(),
|
||
_ => {
|
||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||
"error": "Missing or empty 'name' field"
|
||
}))).into_response();
|
||
}
|
||
};
|
||
|
||
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);
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
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<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);
|
||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||
"error": format!("Error moving file: {}", err)
|
||
}))).into_response()
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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 {
|
||
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<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,
|
||
_ => 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<String, String>,
|
||
compression_service: &dyn CompressionPort,
|
||
) -> 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 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<String>,
|
||
} |