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-08 13:40:23 +01:00
|
|
|
|
use crate::application::ports::file_ports::OptimizedFileContent;
|
2025-03-26 18:33:22 +01:00
|
|
|
|
use crate::common::di::AppState;
|
2026-02-14 10:34:07 +01:00
|
|
|
|
use crate::interfaces::middleware::auth::{AuthUser, OptionalUserId};
|
2026-02-24 15:11:56 +01:00
|
|
|
|
use std::sync::Arc;
|
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
|
2026-02-24 15:11:56 +01:00
|
|
|
|
type GlobalState = Arc<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
|
|
|
|
*
|
2026-02-08 13:40:23 +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 {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// UPLOAD
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
/// Streaming file upload — constant ~64 KB RAM regardless of file size.
|
2026-02-14 01:29:34 +01:00
|
|
|
|
///
|
2026-03-01 21:47:39 +01:00
|
|
|
|
/// **Hash-on-Write**: BLAKE3 is computed while spooling the multipart
|
2026-02-15 17:53:25 +01:00
|
|
|
|
/// body to the temp file. This eliminates the second sequential read
|
|
|
|
|
|
/// that dedup_service would otherwise need, cutting total I/O in half.
|
2025-03-17 21:28:08 +01:00
|
|
|
|
pub async fn upload_file(
|
2026-02-08 13:40:23 +01:00
|
|
|
|
State(state): State<GlobalState>,
|
2026-02-15 17:53:25 +01:00
|
|
|
|
auth_user: AuthUser,
|
2026-02-24 17:15:36 +01:00
|
|
|
|
multipart: Multipart,
|
2025-03-17 21:28:08 +01:00
|
|
|
|
) -> impl IntoResponse {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
match Self::upload_file_inner(&state, &auth_user, multipart).await {
|
|
|
|
|
|
Ok(file) => 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 the typed `FileDto` on success so callers can use it
|
|
|
|
|
|
/// directly (e.g. for thumbnail generation) without re-parsing JSON.
|
|
|
|
|
|
async fn upload_file_inner(
|
|
|
|
|
|
state: &GlobalState,
|
|
|
|
|
|
auth_user: &AuthUser,
|
|
|
|
|
|
mut multipart: Multipart,
|
|
|
|
|
|
) -> Result<crate::application::dtos::file_dto::FileDto, Response<Body>> {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
let upload_service = &state.applications.file_upload_service;
|
2026-02-03 17:59:04 +01:00
|
|
|
|
let mut folder_id: Option<String> = None;
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
tracing::debug!("📤 Processing streaming file upload (hash-on-write)");
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
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-08 13:40:23 +01:00
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
|
if name == "folder_id" {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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-08 13:40:23 +01:00
|
|
|
|
|
2025-03-17 21:28:08 +01:00
|
|
|
|
if name == "file" {
|
2026-02-16 17:58:50 +01:00
|
|
|
|
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();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
let content_type = field
|
|
|
|
|
|
.content_type()
|
|
|
|
|
|
.unwrap_or("application/octet-stream")
|
|
|
|
|
|
.to_string();
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// ── 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::<u64>().ok())
|
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
if let Err(err) = storage_svc
|
|
|
|
|
|
.check_storage_quota(&auth_user.id, estimated_size)
|
2026-02-14 01:29:34 +01:00
|
|
|
|
.await
|
|
|
|
|
|
{
|
2026-02-15 17:53:25 +01:00
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"⛔ UPLOAD REJECTED (early quota): user={}, file={}, est_size={}",
|
|
|
|
|
|
auth_user.username,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
estimated_size
|
|
|
|
|
|
);
|
2026-02-24 17:15:36 +01:00
|
|
|
|
return Err(Self::quota_error_response(err));
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
2025-03-17 21:28:08 +01:00
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// ── Spool multipart field to temp file + hash-on-write ──
|
2026-03-02 00:12:33 +01:00
|
|
|
|
// .dedup_temp is created once by DedupService::initialize() at startup
|
2026-02-15 17:53:25 +01:00
|
|
|
|
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()));
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
let mut total_size: u64 = 0;
|
2026-03-01 21:47:39 +01:00
|
|
|
|
let mut hasher = blake3::Hasher::new();
|
2026-02-15 17:53:25 +01:00
|
|
|
|
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::<u64>().ok());
|
|
|
|
|
|
if let Some(len) = hint {
|
2026-02-15 18:04:32 +01:00
|
|
|
|
let _ = file.set_len(len).await; // best-effort
|
2026-02-15 17:53:25 +01:00
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
while let Ok(Some(chunk)) = field.chunk().await {
|
|
|
|
|
|
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))?;
|
|
|
|
|
|
}
|
|
|
|
|
|
tokio::io::AsyncWriteExt::flush(&mut writer)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| format!("Failed to flush temp file: {}", e))?;
|
|
|
|
|
|
Ok(())
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|
2026-02-15 17:53:25 +01:00
|
|
|
|
.await;
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
if let Err(e) = spool_result {
|
|
|
|
|
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
|
|
|
|
|
tracing::error!("❌ UPLOAD SPOOL FAILED: {} - {}", filename, e);
|
2026-02-24 17:15:36 +01:00
|
|
|
|
return Err(Self::domain_error_response(
|
2026-02-15 17:53:25 +01:00
|
|
|
|
crate::common::errors::DomainError::internal_error("FileUpload", e),
|
2026-02-24 17:15:36 +01:00
|
|
|
|
));
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-25 23:41:16 +01:00
|
|
|
|
// Empty file — use streaming path with the (empty) temp file
|
2026-02-15 17:53:25 +01:00
|
|
|
|
if total_size == 0 {
|
2026-03-01 21:47:39 +01:00
|
|
|
|
let hash = hasher.finalize().to_hex().to_string();
|
2026-02-24 17:15:36 +01:00
|
|
|
|
return upload_service
|
2026-02-25 23:41:16 +01:00
|
|
|
|
.upload_file_streaming(
|
|
|
|
|
|
filename,
|
|
|
|
|
|
folder_id,
|
|
|
|
|
|
content_type,
|
|
|
|
|
|
&temp_path,
|
|
|
|
|
|
0,
|
|
|
|
|
|
Some(hash),
|
|
|
|
|
|
)
|
2026-02-14 01:29:34 +01:00
|
|
|
|
.await
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.map_err(Self::domain_error_response);
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// Finalize hash
|
2026-03-01 21:47:39 +01:00
|
|
|
|
let hash = hasher.finalize().to_hex().to_string();
|
2026-02-15 17:53:25 +01:00
|
|
|
|
|
2026-02-24 23:15:10 +01:00
|
|
|
|
// ── MIME detection (magic bytes + extension fallback) ─
|
|
|
|
|
|
let content_type = crate::common::mime_detect::refine_content_type_from_file(
|
|
|
|
|
|
&temp_path,
|
|
|
|
|
|
&filename,
|
|
|
|
|
|
&content_type,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// ── 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)
|
2026-02-14 10:34:07 +01:00
|
|
|
|
.await
|
2026-02-15 17:53:25 +01:00
|
|
|
|
{
|
2026-02-15 18:04:32 +01:00
|
|
|
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"⛔ UPLOAD REJECTED (quota): user={}, file={}, size={}",
|
|
|
|
|
|
auth_user.username,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
total_size
|
|
|
|
|
|
);
|
2026-02-24 17:15:36 +01:00
|
|
|
|
return Err(Self::quota_error_response(err));
|
2026-02-15 18:04:32 +01:00
|
|
|
|
}
|
2026-02-14 10:34:07 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// ── Streaming upload (temp file → blob store, hash pre-computed) ─
|
2026-02-08 13:40:23 +01:00
|
|
|
|
match upload_service
|
2026-02-15 17:53:25 +01:00
|
|
|
|
.upload_file_streaming(
|
2026-02-14 01:29:34 +01:00
|
|
|
|
filename.clone(),
|
|
|
|
|
|
folder_id,
|
|
|
|
|
|
content_type,
|
2026-02-15 17:53:25 +01:00
|
|
|
|
&temp_path,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
total_size,
|
2026-02-15 17:53:25 +01:00
|
|
|
|
Some(hash),
|
2026-02-14 01:29:34 +01:00
|
|
|
|
)
|
2026-02-08 13:40:23 +01:00
|
|
|
|
.await
|
|
|
|
|
|
{
|
2026-02-15 17:53:25 +01:00
|
|
|
|
Ok(file) => {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
tracing::info!(
|
2026-02-15 17:53:25 +01:00
|
|
|
|
"✅ STREAMING UPLOAD: {} ({} bytes, ID: {})",
|
2026-02-14 01:29:34 +01:00
|
|
|
|
filename,
|
|
|
|
|
|
total_size,
|
|
|
|
|
|
file.id
|
2026-02-08 13:40:23 +01:00
|
|
|
|
);
|
2026-02-24 17:15:36 +01:00
|
|
|
|
return Ok(file);
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
|
|
|
|
|
tracing::error!("❌ UPLOAD FAILED: {} - {}", filename, err);
|
2026-02-24 17:15:36 +01:00
|
|
|
|
return Err(Self::domain_error_response(err));
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
2025-03-17 21:28:08 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
Err((
|
2026-02-14 01:29:34 +01:00
|
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
|
|
|
Json(serde_json::json!({
|
|
|
|
|
|
"error": "No file provided"
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.into_response())
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 13:40:23 +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 {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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-08 13:40:23 +01:00
|
|
|
|
|
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
|
|
|
|
};
|
2026-02-08 13:40:23 +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
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-08 13:40:23 +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-08 13:40:23 +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-08 13:40:23 +01:00
|
|
|
|
|
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-08 13:40:23 +01:00
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +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 {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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")
|
|
|
|
|
|
{
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-13 09:32:16 +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();
|
2026-02-13 09:32:16 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 13:40:23 +01:00
|
|
|
|
let etag = format!("\"{}-{}\"", id, file_dto.modified_at);
|
|
|
|
|
|
|
|
|
|
|
|
// ── ETag (304 Not Modified) ──────────────────────────────────
|
2026-02-14 01:26:02 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
// ── Range Requests ───────────────────────────────────────────
|
2026-02-14 01:26:02 +01:00
|
|
|
|
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, ¶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
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
// ── Normal download (delegated to service) ───────────────────
|
|
|
|
|
|
let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms);
|
|
|
|
|
|
|
2026-02-14 01:29:34 +01:00
|
|
|
|
let accept_webp = headers
|
|
|
|
|
|
.get(header::ACCEPT)
|
2026-02-08 13:40:23 +01:00
|
|
|
|
.and_then(|v| v.to_str().ok())
|
2026-02-14 01:26:02 +01:00
|
|
|
|
.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-08 13:40:23 +01:00
|
|
|
|
|
2026-02-14 01:29:34 +01:00
|
|
|
|
match retrieval
|
2026-02-15 17:53:25 +01:00
|
|
|
|
.get_file_optimized_preloaded(&id, file_dto.clone(), accept_webp, prefer_original)
|
2026-02-14 01:29:34 +01:00
|
|
|
|
.await
|
|
|
|
|
|
{
|
2026-02-08 13:40:23 +01:00
|
|
|
|
Ok((_file, content)) => match content {
|
2026-02-14 01:29:34 +01:00
|
|
|
|
OptimizedFileContent::Bytes {
|
|
|
|
|
|
data, mime_type, ..
|
2026-02-25 10:28:34 +01:00
|
|
|
|
} => Self::build_cached_response(data, &mime_type, &disposition, &etag)
|
|
|
|
|
|
.into_response(),
|
2026-02-14 01:29:34 +01:00
|
|
|
|
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(),
|
2026-02-08 13:40:23 +01:00
|
|
|
|
},
|
|
|
|
|
|
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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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>,
|
2026-02-24 09:52:22 +01:00
|
|
|
|
headers: HeaderMap,
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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) => {
|
2026-02-24 09:52:22 +01:00
|
|
|
|
// Compute lightweight ETag from max modified_at + count
|
|
|
|
|
|
let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0);
|
|
|
|
|
|
let count = files.len();
|
|
|
|
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
|
std::hash::Hash::hash(&max_mod, &mut hasher);
|
|
|
|
|
|
std::hash::Hash::hash(&count, &mut hasher);
|
|
|
|
|
|
let etag = format!("\"{:x}\"", std::hash::Hasher::finish(&hasher));
|
|
|
|
|
|
|
|
|
|
|
|
// 304 Not Modified if client already has this version
|
|
|
|
|
|
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
|
|
|
|
|
&& let Ok(client_etag) = inm.to_str()
|
|
|
|
|
|
&& client_etag == etag
|
|
|
|
|
|
{
|
|
|
|
|
|
return Response::builder()
|
|
|
|
|
|
.status(StatusCode::NOT_MODIFIED)
|
|
|
|
|
|
.header(header::ETAG, &etag)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 13:40:23 +01:00
|
|
|
|
tracing::info!("Found {} files", files.len());
|
2026-02-24 09:52:22 +01:00
|
|
|
|
let mut resp = (StatusCode::OK, Json(files)).into_response();
|
2026-02-25 10:28:34 +01:00
|
|
|
|
resp.headers_mut()
|
|
|
|
|
|
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
2026-02-24 09:52:22 +01:00
|
|
|
|
resp
|
2026-02-08 13:40:23 +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": format!("Error listing files: {}", err)
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Uploads a file and generates thumbnails in the background for images.
|
|
|
|
|
|
///
|
2026-02-24 17:15:36 +01:00
|
|
|
|
/// Delegates to [`Self::upload_file_inner`] and, on success, spawns
|
|
|
|
|
|
/// a background task to generate all thumbnail sizes before serialising
|
|
|
|
|
|
/// the `FileDto` once.
|
2026-02-08 13:40:23 +01:00
|
|
|
|
pub async fn upload_file_with_thumbnails(
|
|
|
|
|
|
State(state): State<GlobalState>,
|
2026-02-14 10:34:07 +01:00
|
|
|
|
auth_user: AuthUser,
|
2026-02-08 13:40:23 +01:00
|
|
|
|
multipart: Multipart,
|
|
|
|
|
|
) -> impl IntoResponse {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let file = match Self::upload_file_inner(&state, &auth_user, multipart).await {
|
|
|
|
|
|
Ok(f) => f,
|
|
|
|
|
|
Err(response) => return response.into_response(),
|
|
|
|
|
|
};
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// Generate thumbnails for supported images in background
|
2026-02-25 10:28:34 +01:00
|
|
|
|
if state
|
|
|
|
|
|
.core
|
|
|
|
|
|
.thumbnail_service
|
|
|
|
|
|
.is_supported_image(&file.mime_type)
|
|
|
|
|
|
{
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let file_id = file.id.clone();
|
|
|
|
|
|
let file_path_rel = file.path.clone();
|
|
|
|
|
|
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
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
Self::created_json_response(&file).into_response()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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)
|
2026-02-08 13:40:23 +01:00
|
|
|
|
.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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
|
Err(err) => {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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`.
|
2026-02-13 08:54:51 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// When auth is available, uses trash-first deletion; otherwise falls back
|
|
|
|
|
|
/// to permanent delete so the endpoint works with or without auth.
|
2026-02-08 13:40:23 +01:00
|
|
|
|
pub async fn delete_file(
|
|
|
|
|
|
State(state): State<GlobalState>,
|
2026-02-13 08:54:51 +01:00
|
|
|
|
OptionalUserId(user_id): OptionalUserId,
|
2026-02-08 13:40:23 +01:00
|
|
|
|
Path(id): Path<String>,
|
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
|
let mgmt = &state.applications.file_management_service;
|
|
|
|
|
|
|
2026-02-13 08:54:51 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-02-13 08:54:51 +01:00
|
|
|
|
} 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(),
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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")
|
|
|
|
|
|
{
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 13:40:23 +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!({
|
2026-02-08 13:40:23 +01:00
|
|
|
|
"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
|
|
|
|
},
|
2026-02-08 13:40:23 +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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
2025-03-17 21:28:08 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// PRIVATE HELPERS
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/// 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");
|
2026-02-08 13:40:23 +01:00
|
|
|
|
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,
|
2026-02-14 10:34:07 +01:00
|
|
|
|
crate::common::errors::ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
|
2026-02-08 13:40:23 +01:00
|
|
|
|
_ => 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 10:34:07 +01:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 14:12:53 +01:00
|
|
|
|
/// Build response for cached/small files.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Compression is handled uniformly by `CompressionLayer` (tower-http)
|
|
|
|
|
|
/// which negotiates `Accept-Encoding` and applies gzip/brotli in streaming
|
|
|
|
|
|
/// mode. No manual compression is done here to avoid double-encoding.
|
|
|
|
|
|
fn build_cached_response(
|
2026-02-03 17:59:04 +01:00
|
|
|
|
content: Bytes,
|
|
|
|
|
|
mime_type: &str,
|
|
|
|
|
|
disposition: &str,
|
|
|
|
|
|
etag: &str,
|
|
|
|
|
|
) -> Response<Body> {
|
2026-02-22 14:12:53 +01:00
|
|
|
|
Response::builder()
|
2026-02-03 17:59:04 +01:00
|
|
|
|
.status(StatusCode::OK)
|
2026-02-22 14:12:53 +01:00
|
|
|
|
.header(header::CONTENT_TYPE, mime_type)
|
2026-02-03 17:59:04 +01:00
|
|
|
|
.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-22 14:12:53 +01:00
|
|
|
|
.header(header::VARY, "Accept-Encoding")
|
|
|
|
|
|
.header(header::CONTENT_LENGTH, content.len())
|
|
|
|
|
|
.body(Body::from(content))
|
|
|
|
|
|
.unwrap()
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
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
|
|
|
|
}
|