2025-03-17 21:28:08 +01:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
use axum::{
|
2025-03-19 00:44:27 +01:00
|
|
|
extract::{Path, State, Multipart, Query},
|
2026-02-03 17:59:04 +01:00
|
|
|
http::{StatusCode, header, HeaderMap, Response},
|
2025-03-17 21:28:08 +01:00
|
|
|
response::IntoResponse,
|
2026-02-03 17:59:04 +01:00
|
|
|
body::Body,
|
2025-03-17 21:28:08 +01:00
|
|
|
Json,
|
|
|
|
|
};
|
2026-02-03 17:59:04 +01:00
|
|
|
use bytes::Bytes;
|
2025-03-17 21:28:08 +01:00
|
|
|
use serde::Deserialize;
|
2025-03-19 00:44:27 +01:00
|
|
|
use std::collections::HashMap;
|
2026-02-03 17:59:04 +01:00
|
|
|
use http_range_header::parse_range_header;
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
use crate::application::services::file_service::{FileService, FileServiceError};
|
|
|
|
|
use crate::infrastructure::services::compression_service::{
|
|
|
|
|
CompressionService, GzipCompressionService, CompressionLevel
|
|
|
|
|
};
|
2025-03-26 18:33:22 +01:00
|
|
|
use crate::common::di::AppState;
|
2026-02-07 04:02:38 +01:00
|
|
|
use crate::interfaces::middleware::auth::CurrentUserId;
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/**
|
|
|
|
|
* Type aliases for dependency injection state.
|
|
|
|
|
* These aliases improve code readability when working with service dependencies.
|
|
|
|
|
*/
|
|
|
|
|
/// State containing the file service for dependency injection
|
2025-03-26 18:33:22 +01:00
|
|
|
type FileServiceState = Arc<FileService>;
|
2025-03-26 19:08:07 +01:00
|
|
|
/// 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.
|
|
|
|
|
*
|
|
|
|
|
* The FileHandler is responsible for processing HTTP requests related to file operations.
|
|
|
|
|
* It handles:
|
|
|
|
|
*
|
|
|
|
|
* 1. File uploads through multipart form data
|
|
|
|
|
* 2. File downloads with optional compression
|
|
|
|
|
* 3. Listing files in folders
|
|
|
|
|
* 4. Moving files between folders
|
|
|
|
|
* 5. Deleting files (with trash integration)
|
|
|
|
|
*
|
|
|
|
|
* This component acts as an adapter in the hexagonal architecture, translating
|
|
|
|
|
* between HTTP requests/responses and application service calls. It handles
|
|
|
|
|
* HTTP-specific concerns like status codes, headers, and request parsing while
|
|
|
|
|
* delegating business logic to the application services.
|
|
|
|
|
*/
|
2025-03-17 21:28:08 +01:00
|
|
|
pub struct FileHandler;
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
/// Threshold for using streaming upload (files >= 1MB use streaming)
|
|
|
|
|
const STREAMING_UPLOAD_THRESHOLD: usize = 1 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
/// Threshold for write-behind cache (files < 256KB get instant response)
|
|
|
|
|
const WRITE_BEHIND_THRESHOLD: usize = 256 * 1024;
|
|
|
|
|
|
2025-03-17 21:28:08 +01:00
|
|
|
impl FileHandler {
|
2026-02-03 17:59:04 +01:00
|
|
|
/// Uploads a file with TRUE STREAMING support and Write-Behind Cache
|
|
|
|
|
///
|
|
|
|
|
/// Three-tier upload strategy:
|
|
|
|
|
///
|
|
|
|
|
/// 1. INSTANT (<256KB): Write-behind cache
|
|
|
|
|
/// - Store in RAM, respond immediately
|
|
|
|
|
/// - Flush to disk asynchronously
|
|
|
|
|
/// - User perceives ~0ms latency
|
|
|
|
|
///
|
|
|
|
|
/// 2. BUFFERED (256KB - 1MB): In-memory processing
|
|
|
|
|
/// - Fast for medium files
|
|
|
|
|
/// - Direct write to disk before response
|
|
|
|
|
///
|
|
|
|
|
/// 3. STREAMING (≥1MB): Direct disk writes
|
|
|
|
|
/// - Constant memory regardless of file size
|
|
|
|
|
/// - Uses atomic rename for crash safety
|
2025-03-17 21:28:08 +01:00
|
|
|
pub async fn upload_file(
|
2025-03-26 18:33:22 +01:00
|
|
|
State(service): State<FileServiceState>,
|
2025-03-17 21:28:08 +01:00
|
|
|
mut multipart: Multipart,
|
|
|
|
|
) -> impl IntoResponse {
|
2026-02-03 17:59:04 +01:00
|
|
|
use futures::stream;
|
|
|
|
|
use std::pin::Pin;
|
|
|
|
|
|
|
|
|
|
let mut folder_id: Option<String> = None;
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
tracing::debug!("📤 Processing file upload request");
|
2025-03-23 22:44:18 +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-03 17:59:04 +01:00
|
|
|
|
|
|
|
|
if name == "folder_id" {
|
|
|
|
|
let folder_id_value = field.text().await.unwrap_or_default();
|
|
|
|
|
if !folder_id_value.is_empty() {
|
|
|
|
|
folder_id = Some(folder_id_value);
|
|
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
tracing::info!("📤 UPLOAD START: {} (folder: {:?})", filename, folder_id);
|
2025-03-23 22:44:18 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Collect all chunks from the field - we need to consume the field completely
|
|
|
|
|
// to avoid borrow issues with multipart
|
|
|
|
|
let mut chunks: Vec<Bytes> = Vec::new();
|
|
|
|
|
let mut total_size: usize = 0;
|
|
|
|
|
let mut field = field;
|
2025-03-23 22:44:18 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
while let Ok(Some(chunk)) = field.chunk().await {
|
|
|
|
|
total_size += chunk.len();
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
|
|
|
|
|
// Log progress every 10MB
|
|
|
|
|
if total_size > 0 && total_size % (10 * 1024 * 1024) < chunks.last().map(|c| c.len()).unwrap_or(0) {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"📥 Upload receiving: {} - {}MB",
|
|
|
|
|
filename,
|
|
|
|
|
total_size / (1024 * 1024)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Empty file check
|
|
|
|
|
if chunks.is_empty() {
|
|
|
|
|
return Self::upload_empty_file(service, filename, folder_id, content_type).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decide upload strategy based on total size
|
|
|
|
|
if total_size >= STREAMING_UPLOAD_THRESHOLD {
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
// STREAMING UPLOAD - For large files
|
|
|
|
|
// Create a stream from collected chunks and write to disk
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"📡 STREAMING UPLOAD: {} ({} MB, {} chunks)",
|
|
|
|
|
filename,
|
|
|
|
|
total_size / (1024 * 1024),
|
|
|
|
|
chunks.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Convert chunks to a stream
|
|
|
|
|
let chunk_stream = stream::iter(
|
|
|
|
|
chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c))
|
|
|
|
|
);
|
|
|
|
|
let pinned_stream: Pin<Box<dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
|
|
|
|
Box::pin(chunk_stream);
|
|
|
|
|
|
|
|
|
|
// Use streaming upload - writes directly to disk
|
|
|
|
|
match service.upload_file_from_stream(
|
|
|
|
|
filename.clone(),
|
|
|
|
|
folder_id.clone(),
|
|
|
|
|
content_type.clone(),
|
|
|
|
|
pinned_stream,
|
|
|
|
|
).await {
|
|
|
|
|
Ok(file) => {
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"✅ STREAMING UPLOAD COMPLETE: {} ({} MB, ID: {})",
|
|
|
|
|
filename,
|
|
|
|
|
total_size / (1024 * 1024),
|
|
|
|
|
file.id
|
|
|
|
|
);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&file).unwrap()))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("❌ STREAMING UPLOAD FAILED: {} - {}", filename, err);
|
|
|
|
|
return Self::error_response(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
// BUFFERED UPLOAD - For small files (<1MB)
|
|
|
|
|
// Faster for small files as we avoid temp file overhead
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
tracing::debug!("💨 BUFFERED UPLOAD: {} ({} bytes)", filename, total_size);
|
|
|
|
|
|
|
|
|
|
// Combine chunks efficiently
|
|
|
|
|
let data = 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
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match service.upload_file_from_bytes(
|
|
|
|
|
filename.clone(),
|
|
|
|
|
folder_id.clone(),
|
|
|
|
|
content_type.clone(),
|
|
|
|
|
data,
|
|
|
|
|
).await {
|
|
|
|
|
Ok(file) => {
|
|
|
|
|
tracing::info!("✅ BUFFERED UPLOAD COMPLETE: {} (ID: {})", filename, file.id);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&file).unwrap()))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("❌ BUFFERED UPLOAD FAILED: {} - {}", filename, err);
|
|
|
|
|
return Self::error_response(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
tracing::warn!("Upload request missing file field");
|
|
|
|
|
(StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
|
|
|
|
"error": "No file provided"
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle empty file uploads
|
|
|
|
|
async fn upload_empty_file(
|
|
|
|
|
service: Arc<FileService>,
|
|
|
|
|
filename: String,
|
|
|
|
|
folder_id: Option<String>,
|
|
|
|
|
content_type: String,
|
|
|
|
|
) -> Response<axum::body::Body> {
|
|
|
|
|
tracing::debug!("Uploading empty file: {}", filename);
|
|
|
|
|
match service.upload_file_from_bytes(filename.clone(), folder_id, content_type, vec![]).await {
|
|
|
|
|
Ok(file) => {
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&file).unwrap()))
|
|
|
|
|
.unwrap()
|
|
|
|
|
},
|
|
|
|
|
Err(err) => Self::error_response(err),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Uploads a file with Write-Behind Cache for instant response
|
|
|
|
|
///
|
|
|
|
|
/// This version uses AppState to access the write-behind cache,
|
|
|
|
|
/// enabling ~0ms perceived latency for small files (<256KB).
|
|
|
|
|
///
|
|
|
|
|
/// Flow for small files:
|
|
|
|
|
/// 1. Generate file ID and metadata
|
|
|
|
|
/// 2. Store content in write-behind cache (+ dedup blob store)
|
|
|
|
|
/// 3. Respond immediately with 201 Created
|
|
|
|
|
/// 4. Background worker flushes to disk
|
|
|
|
|
///
|
|
|
|
|
/// Deduplication:
|
|
|
|
|
/// - All uploads are stored in the dedup blob store
|
|
|
|
|
/// - Duplicate content is detected by SHA-256 hash
|
|
|
|
|
/// - Only one copy of identical content is stored
|
|
|
|
|
pub async fn upload_file_with_cache(
|
|
|
|
|
State(state): State<GlobalState>,
|
|
|
|
|
mut multipart: Multipart,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
use futures::stream;
|
|
|
|
|
use std::pin::Pin;
|
|
|
|
|
use crate::infrastructure::services::write_behind_cache::WriteBehindCache;
|
|
|
|
|
|
|
|
|
|
let service = &state.applications.file_service_concrete;
|
|
|
|
|
let write_behind = &state.core.write_behind_cache;
|
|
|
|
|
let dedup_service = &state.core.dedup_service;
|
|
|
|
|
|
|
|
|
|
let mut folder_id: Option<String> = None;
|
|
|
|
|
|
|
|
|
|
tracing::debug!("📤 Processing file upload request (with write-behind cache + dedup)");
|
|
|
|
|
|
|
|
|
|
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
|
|
|
|
|
let name = field.name().unwrap_or("").to_string();
|
2025-03-23 22:44:18 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
if name == "folder_id" {
|
|
|
|
|
let folder_id_value = field.text().await.unwrap_or_default();
|
|
|
|
|
if !folder_id_value.is_empty() {
|
|
|
|
|
folder_id = Some(folder_id_value);
|
|
|
|
|
}
|
|
|
|
|
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 - handle separately
|
|
|
|
|
if chunks.is_empty() {
|
|
|
|
|
return Self::upload_empty_file(service.clone(), filename, folder_id, content_type).await.into_response();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
// DEDUPLICATION: Store content in blob store for dedup tracking
|
|
|
|
|
// This runs in parallel with normal upload to track duplicates
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
let dedup_data: Vec<u8> = {
|
|
|
|
|
let mut combined = Vec::with_capacity(total_size);
|
|
|
|
|
for chunk in &chunks {
|
|
|
|
|
combined.extend_from_slice(chunk);
|
|
|
|
|
}
|
|
|
|
|
combined
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Store in dedup blob store (async, non-blocking for response)
|
|
|
|
|
let dedup_result = dedup_service.store_bytes(&dedup_data, Some(content_type.clone())).await;
|
|
|
|
|
match &dedup_result {
|
|
|
|
|
Ok(result) => {
|
|
|
|
|
if result.was_deduplicated() {
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"🔗 DEDUP: {} - content already exists (hash: {}, saved {} bytes)",
|
|
|
|
|
filename,
|
|
|
|
|
&result.hash()[..12],
|
|
|
|
|
result.size()
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"💾 DEDUP: {} - new content stored (hash: {})",
|
|
|
|
|
filename,
|
|
|
|
|
&result.hash()[..12]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Dedup failure is not fatal - continue with normal upload
|
|
|
|
|
tracing::warn!("⚠️ DEDUP: Failed to store in blob store: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"📤 UPLOAD: {} ({} bytes, folder: {:?}, strategy: {})",
|
|
|
|
|
filename,
|
|
|
|
|
total_size,
|
|
|
|
|
folder_id,
|
|
|
|
|
if total_size < WRITE_BEHIND_THRESHOLD { "WRITE-BEHIND" }
|
|
|
|
|
else if total_size < STREAMING_UPLOAD_THRESHOLD { "BUFFERED" }
|
|
|
|
|
else { "STREAMING" }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 1: WRITE-BEHIND PURO (<256KB) - Zero latency upload
|
|
|
|
|
// 1. Register metadata (~0.1ms)
|
|
|
|
|
// 2. Cache content in RAM
|
|
|
|
|
// 3. Respond 201 IMMEDIATELY
|
|
|
|
|
// 4. Background: flush to disk
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
if total_size < WRITE_BEHIND_THRESHOLD && WriteBehindCache::is_eligible(total_size) {
|
|
|
|
|
// Combine chunks
|
|
|
|
|
let data: Bytes = if chunks.len() == 1 {
|
|
|
|
|
chunks.into_iter().next().unwrap()
|
|
|
|
|
} else {
|
|
|
|
|
let mut combined = Vec::with_capacity(total_size);
|
|
|
|
|
for chunk in chunks {
|
|
|
|
|
combined.extend_from_slice(&chunk);
|
|
|
|
|
}
|
|
|
|
|
combined.into()
|
|
|
|
|
};
|
2025-03-23 22:44:18 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Register file metadata WITHOUT writing to disk
|
|
|
|
|
match service.register_file_deferred(
|
|
|
|
|
filename.clone(),
|
|
|
|
|
folder_id.clone(),
|
|
|
|
|
content_type.clone(),
|
|
|
|
|
total_size as u64,
|
|
|
|
|
).await {
|
|
|
|
|
Ok((file, target_path)) => {
|
|
|
|
|
// Put content in write-behind cache for:
|
|
|
|
|
// 1. Immediate reads (before flush completes)
|
|
|
|
|
// 2. Background flush to disk
|
|
|
|
|
if let Err(e) = write_behind.put_pending(
|
|
|
|
|
file.id.clone(),
|
|
|
|
|
data,
|
|
|
|
|
target_path,
|
|
|
|
|
).await {
|
|
|
|
|
tracing::error!("❌ Write-behind cache failed: {} - {}", file.id, e);
|
|
|
|
|
// Fallback: return error (file exists in metadata but not on disk)
|
|
|
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
|
|
|
|
"error": format!("Failed to queue file write: {}", e)
|
|
|
|
|
}))).into_response();
|
2025-04-12 12:21:57 +02:00
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
|
|
|
|
|
tracing::info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", filename, file.id);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&file).unwrap()))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("❌ WRITE-BEHIND REGISTRATION FAILED: {} - {}", filename, err);
|
|
|
|
|
return Self::error_response(err).into_response();
|
2025-04-12 12:21:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 2: STREAMING UPLOAD (≥1MB) - Direct disk writes
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
if total_size >= STREAMING_UPLOAD_THRESHOLD {
|
|
|
|
|
let chunk_stream = stream::iter(
|
|
|
|
|
chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c))
|
|
|
|
|
);
|
|
|
|
|
let pinned_stream: Pin<Box<dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
|
|
|
|
Box::pin(chunk_stream);
|
2025-04-12 12:21:57 +02:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
match service.upload_file_from_stream(
|
|
|
|
|
filename.clone(),
|
|
|
|
|
folder_id.clone(),
|
|
|
|
|
content_type.clone(),
|
|
|
|
|
pinned_stream,
|
|
|
|
|
).await {
|
|
|
|
|
Ok(file) => {
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"✅ STREAMING UPLOAD COMPLETE: {} ({} MB, ID: {})",
|
|
|
|
|
filename,
|
|
|
|
|
total_size / (1024 * 1024),
|
|
|
|
|
file.id
|
|
|
|
|
);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&file).unwrap()))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("❌ STREAMING UPLOAD FAILED: {} - {}", filename, err);
|
|
|
|
|
return Self::error_response(err).into_response();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 3: BUFFERED UPLOAD (256KB - 1MB)
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
let data = 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
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match service.upload_file_from_bytes(
|
|
|
|
|
filename.clone(),
|
|
|
|
|
folder_id.clone(),
|
|
|
|
|
content_type.clone(),
|
|
|
|
|
data,
|
|
|
|
|
).await {
|
|
|
|
|
Ok(file) => {
|
|
|
|
|
tracing::info!("✅ BUFFERED UPLOAD COMPLETE: {} (ID: {})", filename, file.id);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&file).unwrap()))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("❌ BUFFERED UPLOAD FAILED: {} - {}", filename, err);
|
|
|
|
|
return Self::error_response(err).into_response();
|
|
|
|
|
}
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
|
|
|
|
|
(StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
|
|
|
|
"error": "No file provided"
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build error response for upload failures
|
|
|
|
|
fn error_response(err: FileServiceError) -> Response<axum::body::Body> {
|
|
|
|
|
let status = match &err {
|
|
|
|
|
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
|
|
|
|
FileServiceError::AccessError(_) => StatusCode::SERVICE_UNAVAILABLE,
|
|
|
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(status)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::json!({
|
|
|
|
|
"error": format!("Error uploading file: {}", err)
|
|
|
|
|
}).to_string()))
|
|
|
|
|
.unwrap()
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
/// Get a thumbnail for an image file
|
|
|
|
|
///
|
|
|
|
|
/// Supports three sizes:
|
|
|
|
|
/// - icon: 150x150 (for file listings)
|
|
|
|
|
/// - preview: 400x400 (for gallery view)
|
|
|
|
|
/// - large: 800x800 (for detail view)
|
|
|
|
|
///
|
|
|
|
|
/// Thumbnails are:
|
|
|
|
|
/// - Generated on-demand if not cached
|
|
|
|
|
/// - Stored as WebP for smaller size
|
|
|
|
|
/// - Cached in memory for fast repeated access
|
|
|
|
|
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-03 17:59:04 +01:00
|
|
|
use crate::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize};
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
let service = &state.applications.file_service_concrete;
|
|
|
|
|
let thumbnail_service = &state.core.thumbnail_service;
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Parse size parameter
|
|
|
|
|
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();
|
|
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
};
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Get file info
|
|
|
|
|
let file = match 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();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if file is an image
|
|
|
|
|
if !ThumbnailService::is_supported_image(&file.mime_type) {
|
|
|
|
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
|
|
|
|
"error": "File is not a supported image type"
|
|
|
|
|
}))).into_response();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build the file path from storage root + relative path
|
|
|
|
|
let storage_root = state.core.path_service.get_root_path();
|
|
|
|
|
let file_path = storage_root.join(&file.path);
|
|
|
|
|
|
|
|
|
|
// Get or generate thumbnail
|
|
|
|
|
match thumbnail_service.get_thumbnail(&id, thumb_size, &file_path).await {
|
|
|
|
|
Ok(data) => {
|
|
|
|
|
// Generate ETag for caching
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Downloads a file with optimized 3-tier caching strategy
|
|
|
|
|
///
|
|
|
|
|
/// Architecture for maximum performance:
|
|
|
|
|
/// 1. HOT CACHE (RAM): LRU cache for files <10MB - latency ~0.1ms
|
|
|
|
|
/// 2. STREAMING: Direct file streaming for files ≥10MB - no RAM overhead
|
|
|
|
|
///
|
|
|
|
|
/// Also supports:
|
|
|
|
|
/// - ETag-based caching with 304 Not Modified responses
|
|
|
|
|
/// - Optional compression (disabled for streaming to preserve speed)
|
|
|
|
|
/// - Automatic WebP transcoding for images (30-50% smaller)
|
|
|
|
|
pub async fn download_file(
|
|
|
|
|
State(state): State<GlobalState>,
|
|
|
|
|
Path(id): Path<String>,
|
|
|
|
|
Query(params): Query<HashMap<String, String>>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
let service = &state.applications.file_service_concrete;
|
|
|
|
|
let content_cache = &state.core.file_content_cache;
|
|
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
// Get file info first to check it exists and get metadata
|
2026-02-03 17:59:04 +01:00
|
|
|
let file = match service.get_file(&id).await {
|
|
|
|
|
Ok(f) => f,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
let status = match &err {
|
|
|
|
|
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
|
|
|
|
FileServiceError::AccessError(_) => StatusCode::SERVICE_UNAVAILABLE,
|
|
|
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
2025-03-19 00:44:27 +01:00
|
|
|
};
|
2026-02-03 17:59:04 +01:00
|
|
|
return (status, Json(serde_json::json!({
|
|
|
|
|
"error": err.to_string()
|
|
|
|
|
}))).into_response();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Generate ETag based on file ID and modification time
|
|
|
|
|
let etag = format!("\"{}-{}\"", id, file.modified_at);
|
|
|
|
|
|
|
|
|
|
// Check If-None-Match header for ETag validation (304 Not Modified)
|
|
|
|
|
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
|
|
|
|
|
if let Ok(client_etag) = if_none_match.to_str() {
|
|
|
|
|
if client_etag == etag || client_etag == "*" {
|
|
|
|
|
tracing::debug!("ETag match for file {}, returning 304", file.name);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::NOT_MODIFIED)
|
|
|
|
|
.header(header::ETAG, &etag)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// RANGE REQUESTS - For video seeking and resumable downloads
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
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) {
|
|
|
|
|
// Validate and get the first range (we only support single ranges)
|
|
|
|
|
let validated = ranges.validate(file.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;
|
2025-04-02 01:22:05 +02:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
tracing::info!(
|
|
|
|
|
"📡 RANGE REQUEST: {} bytes {}-{}/{}",
|
|
|
|
|
file.name, start, end, file.size
|
|
|
|
|
);
|
2025-04-02 01:22:05 +02:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Determine content disposition for range request
|
|
|
|
|
let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1");
|
2025-04-02 01:22:05 +02:00
|
|
|
let disposition = if force_inline ||
|
|
|
|
|
file.mime_type.starts_with("image/") ||
|
2026-02-03 17:59:04 +01:00
|
|
|
file.mime_type == "application/pdf" ||
|
|
|
|
|
file.mime_type.starts_with("video/") ||
|
|
|
|
|
file.mime_type.starts_with("audio/") {
|
2025-04-02 01:22:05 +02:00
|
|
|
format!("inline; filename=\"{}\"", file.name)
|
|
|
|
|
} else {
|
2025-03-19 00:44:27 +01:00
|
|
|
format!("attachment; filename=\"{}\"", file.name)
|
2025-04-02 01:22:05 +02:00
|
|
|
};
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Use range streaming
|
|
|
|
|
match service.get_file_range_stream(&id, start, Some(end + 1)).await {
|
|
|
|
|
Ok(stream) => {
|
|
|
|
|
let pinned_stream = Box::into_pin(stream);
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
|
|
|
.header(header::CONTENT_TYPE, &file.mime_type)
|
|
|
|
|
.header(header::CONTENT_DISPOSITION, &disposition)
|
|
|
|
|
.header(header::CONTENT_LENGTH, range_length)
|
|
|
|
|
.header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file.size))
|
|
|
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
|
|
|
.header(header::ETAG, &etag)
|
|
|
|
|
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
|
|
|
|
|
.body(Body::from_stream(pinned_stream))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("Error creating range stream: {}", err);
|
|
|
|
|
// Fall through to normal download on error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Range not satisfiable
|
|
|
|
|
tracing::warn!("Range not satisfiable: {} for file size {}", range_str, file.size);
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
|
|
|
|
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size))
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine content disposition
|
|
|
|
|
let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1");
|
|
|
|
|
let disposition = if force_inline ||
|
|
|
|
|
file.mime_type.starts_with("image/") ||
|
|
|
|
|
file.mime_type == "application/pdf" ||
|
|
|
|
|
file.mime_type.starts_with("video/") ||
|
|
|
|
|
file.mime_type.starts_with("audio/") {
|
|
|
|
|
format!("inline; filename=\"{}\"", file.name)
|
|
|
|
|
} else {
|
|
|
|
|
format!("attachment; filename=\"{}\"", file.name)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// File size threshold for streaming (10MB)
|
|
|
|
|
const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024;
|
|
|
|
|
// Threshold for mmap vs streaming (100MB)
|
|
|
|
|
const MMAP_THRESHOLD: u64 = 100 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 0: WRITE-BEHIND CACHE - For recently uploaded small files
|
|
|
|
|
// Serves content directly from RAM if file was just uploaded
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
let write_behind = &state.core.write_behind_cache;
|
|
|
|
|
if let Some(pending_content) = write_behind.get_pending(&id).await {
|
|
|
|
|
tracing::debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file.name, pending_content.len());
|
|
|
|
|
|
|
|
|
|
return Self::build_cached_response(
|
|
|
|
|
pending_content,
|
|
|
|
|
&file.mime_type,
|
|
|
|
|
&disposition,
|
|
|
|
|
&etag,
|
|
|
|
|
file.size,
|
|
|
|
|
¶ms,
|
|
|
|
|
).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 1: HOT CACHE - For small files (<10MB)
|
|
|
|
|
// With automatic WebP transcoding for images (30-50% smaller)
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
if file.size < CACHE_THRESHOLD {
|
|
|
|
|
use crate::infrastructure::services::image_transcode_service::{
|
|
|
|
|
ImageTranscodeService, BrowserCapabilities
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if browser supports WebP and image is transcodable
|
|
|
|
|
let accept_header = headers.get(header::ACCEPT)
|
|
|
|
|
.and_then(|v| v.to_str().ok());
|
|
|
|
|
let browser_caps = BrowserCapabilities::from_accept_header(accept_header);
|
|
|
|
|
let should_transcode = browser_caps.supports_webp
|
|
|
|
|
&& ImageTranscodeService::should_transcode(&file.mime_type, file.size)
|
|
|
|
|
&& params.get("original").map_or(true, |v| v != "true" && v != "1");
|
|
|
|
|
|
|
|
|
|
// Check cache first
|
|
|
|
|
if let Some((cached_content, _cached_etag, _cached_content_type)) = content_cache.get(&id).await {
|
|
|
|
|
tracing::debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file.name, cached_content.len());
|
|
|
|
|
|
|
|
|
|
// Try WebP transcoding for cached content
|
|
|
|
|
if should_transcode {
|
|
|
|
|
let transcode_service = &state.core.image_transcode_service;
|
|
|
|
|
if let Some(format) = browser_caps.best_format() {
|
|
|
|
|
match transcode_service.get_transcoded(
|
|
|
|
|
&id,
|
|
|
|
|
&cached_content,
|
|
|
|
|
&file.mime_type,
|
|
|
|
|
format,
|
|
|
|
|
).await {
|
|
|
|
|
Ok((transcoded, webp_mime, was_transcoded)) => {
|
|
|
|
|
if was_transcoded {
|
|
|
|
|
tracing::debug!("🖼️ WebP transcode: {} -> {} bytes ({:.0}% smaller)",
|
|
|
|
|
cached_content.len(), transcoded.len(),
|
|
|
|
|
(1.0 - transcoded.len() as f64 / cached_content.len() as f64) * 100.0
|
2025-03-19 00:44:27 +01:00
|
|
|
);
|
2026-02-03 17:59:04 +01:00
|
|
|
return Self::build_cached_response(
|
|
|
|
|
transcoded,
|
|
|
|
|
&webp_mime,
|
|
|
|
|
&disposition,
|
|
|
|
|
&etag,
|
|
|
|
|
file.size,
|
|
|
|
|
¶ms,
|
|
|
|
|
).await;
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::debug!("WebP transcode failed, serving original: {}", e);
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Self::build_cached_response(
|
|
|
|
|
cached_content,
|
|
|
|
|
&file.mime_type,
|
|
|
|
|
&disposition,
|
|
|
|
|
&etag,
|
|
|
|
|
file.size,
|
|
|
|
|
¶ms,
|
|
|
|
|
).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache miss - load from disk and cache
|
|
|
|
|
tracing::debug!("💾 TIER 1 Cache MISS: {} - loading from disk", file.name);
|
|
|
|
|
|
|
|
|
|
match service.get_file_content(&id).await {
|
|
|
|
|
Ok(content) => {
|
|
|
|
|
let content_bytes = Bytes::from(content);
|
|
|
|
|
|
|
|
|
|
// Store in cache for next time
|
|
|
|
|
content_cache.put(
|
|
|
|
|
id.clone(),
|
|
|
|
|
content_bytes.clone(),
|
|
|
|
|
etag.clone(),
|
|
|
|
|
file.mime_type.clone()
|
|
|
|
|
).await;
|
|
|
|
|
|
|
|
|
|
// Try WebP transcoding
|
|
|
|
|
if should_transcode {
|
|
|
|
|
let transcode_service = &state.core.image_transcode_service;
|
|
|
|
|
if let Some(format) = browser_caps.best_format() {
|
|
|
|
|
match transcode_service.get_transcoded(
|
|
|
|
|
&id,
|
|
|
|
|
&content_bytes,
|
|
|
|
|
&file.mime_type,
|
|
|
|
|
format,
|
|
|
|
|
).await {
|
|
|
|
|
Ok((transcoded, webp_mime, was_transcoded)) => {
|
|
|
|
|
if was_transcoded {
|
|
|
|
|
tracing::info!("🖼️ WebP transcode: {} {} -> {} bytes ({:.0}% smaller)",
|
|
|
|
|
file.name, content_bytes.len(), transcoded.len(),
|
|
|
|
|
(1.0 - transcoded.len() as f64 / content_bytes.len() as f64) * 100.0
|
2025-03-19 00:44:27 +01:00
|
|
|
);
|
2026-02-03 17:59:04 +01:00
|
|
|
return Self::build_cached_response(
|
|
|
|
|
transcoded,
|
|
|
|
|
&webp_mime,
|
|
|
|
|
&disposition,
|
|
|
|
|
&etag,
|
|
|
|
|
file.size,
|
|
|
|
|
¶ms,
|
|
|
|
|
).await;
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::debug!("WebP transcode failed, serving original: {}", e);
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
|
|
|
|
|
return Self::build_cached_response(
|
|
|
|
|
content_bytes,
|
|
|
|
|
&file.mime_type,
|
|
|
|
|
&disposition,
|
|
|
|
|
&etag,
|
|
|
|
|
file.size,
|
|
|
|
|
¶ms,
|
|
|
|
|
).await;
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("Error reading file content: {}", err);
|
|
|
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
|
|
|
|
"error": format!("Error reading file: {}", err)
|
|
|
|
|
}))).into_response();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 2: MMAP - For medium files (10-100MB)
|
|
|
|
|
// Zero-copy kernel memory mapping for optimal performance
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
if file.size < MMAP_THRESHOLD {
|
|
|
|
|
tracing::info!("🗺️ TIER 2 MMAP: {} ({} MB)", file.name, file.size / (1024 * 1024));
|
|
|
|
|
|
|
|
|
|
match service.get_file_mmap(&id).await {
|
|
|
|
|
Ok(mmap_content) => {
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(header::CONTENT_TYPE, &file.mime_type)
|
|
|
|
|
.header(header::CONTENT_DISPOSITION, &disposition)
|
|
|
|
|
.header(header::CONTENT_LENGTH, file.size)
|
|
|
|
|
.header(header::ETAG, &etag)
|
|
|
|
|
.header(header::CACHE_CONTROL, "private, max-age=3600, must-revalidate")
|
|
|
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
|
|
|
.body(Body::from(mmap_content))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::warn!("MMAP failed, falling back to streaming: {}", err);
|
|
|
|
|
// Fall through to streaming
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
// TIER 3: STREAMING - For very large files (≥100MB)
|
|
|
|
|
// Chunk-based streaming directly from disk to network
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
tracing::info!("📡 TIER 3 STREAMING: {} ({} MB)", file.name, file.size / (1024 * 1024));
|
|
|
|
|
|
|
|
|
|
match service.get_file_stream(&id).await {
|
|
|
|
|
Ok(stream) => {
|
|
|
|
|
// The stream is already Box<dyn Stream<Item = Result<Bytes, io::Error>> + Send>
|
|
|
|
|
// axum's Body::from_stream accepts streams that yield Result<impl Into<Bytes>, Error>
|
|
|
|
|
// We need to pin the boxed stream for use with Body::from_stream
|
|
|
|
|
let pinned_stream = Box::into_pin(stream);
|
|
|
|
|
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(header::CONTENT_TYPE, &file.mime_type)
|
|
|
|
|
.header(header::CONTENT_DISPOSITION, &disposition)
|
|
|
|
|
.header(header::CONTENT_LENGTH, file.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()
|
2025-03-17 21:28:08 +01:00
|
|
|
},
|
2025-03-19 00:44:27 +01:00
|
|
|
Err(err) => {
|
2026-02-03 17:59:04 +01:00
|
|
|
tracing::error!("Error creating file stream: {}", err);
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
// Fallback to regular content loading if streaming fails
|
|
|
|
|
tracing::warn!("Falling back to content-based download for: {}", file.name);
|
|
|
|
|
|
|
|
|
|
match service.get_file_content(&id).await {
|
|
|
|
|
Ok(content) => {
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(header::CONTENT_TYPE, &file.mime_type)
|
|
|
|
|
.header(header::CONTENT_DISPOSITION, &disposition)
|
|
|
|
|
.header(header::CONTENT_LENGTH, content.len())
|
|
|
|
|
.header(header::ETAG, &etag)
|
|
|
|
|
.body(Body::from(content))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into_response()
|
|
|
|
|
},
|
|
|
|
|
Err(content_err) => {
|
|
|
|
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
|
|
|
|
"error": format!("Error reading file: {}", content_err)
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
/// 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>,
|
|
|
|
|
) -> Response<Body> {
|
|
|
|
|
// Check if compression is requested
|
|
|
|
|
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 compression_service = GzipCompressionService::new();
|
|
|
|
|
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) => {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"Compressed {}KB → {}KB ({:.1}x)",
|
|
|
|
|
content.len() / 1024,
|
|
|
|
|
compressed.len() / 1024,
|
|
|
|
|
content.len() as f64 / compressed.len().max(1) as f64
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-17 21:28:08 +01:00
|
|
|
/// Lists files, optionally filtered by folder ID
|
|
|
|
|
pub async fn list_files(
|
2025-03-26 18:33:22 +01:00
|
|
|
State(service): State<FileServiceState>,
|
2025-03-17 21:28:08 +01:00
|
|
|
folder_id: Option<&str>,
|
|
|
|
|
) -> impl IntoResponse {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::info!("Listing files with folder_id: {:?}", folder_id);
|
|
|
|
|
|
|
|
|
|
// Simply use the file service to list files
|
2025-03-17 21:28:08 +01:00
|
|
|
match service.list_files(folder_id).await {
|
|
|
|
|
Ok(files) => {
|
2025-03-23 22:44:18 +01:00
|
|
|
// Log success for debugging purposes
|
|
|
|
|
tracing::info!("Found {} files through the service", files.len());
|
|
|
|
|
|
|
|
|
|
if !files.is_empty() {
|
|
|
|
|
tracing::info!("First file in service list: {} (ID: {})",
|
|
|
|
|
files[0].name, files[0].id);
|
|
|
|
|
} else {
|
|
|
|
|
tracing::info!("No files found in folder through service");
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-12 12:21:57 +02:00
|
|
|
// Devolver respuesta con cabeceras para evitar caché del navegador
|
|
|
|
|
let response = Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
|
|
|
.header("Pragma", "no-cache")
|
|
|
|
|
.header("Expires", "0")
|
|
|
|
|
.body(axum::body::Body::from(serde_json::to_string(&files).unwrap()))
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
response
|
2025-03-17 21:28:08 +01:00
|
|
|
},
|
|
|
|
|
Err(err) => {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::error!("Error listing files through service: {}", err);
|
|
|
|
|
|
2025-03-26 18:33:22 +01:00
|
|
|
let status = StatusCode::INTERNAL_SERVER_ERROR;
|
2025-03-17 21:28:08 +01:00
|
|
|
|
|
|
|
|
// Return a JSON error response
|
|
|
|
|
(status, Json(serde_json::json!({
|
|
|
|
|
"error": err.to_string()
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 17:59:04 +01:00
|
|
|
/// Deletes a file (with trash support and dedup reference counting)
|
|
|
|
|
///
|
|
|
|
|
/// When a file is deleted:
|
|
|
|
|
/// 1. Try to move to trash (soft delete)
|
|
|
|
|
/// 2. If trash fails, do permanent delete
|
|
|
|
|
/// 3. Decrement dedup reference count for the content hash
|
2025-03-17 21:28:08 +01:00
|
|
|
pub async fn delete_file(
|
2025-03-26 18:33:22 +01:00
|
|
|
State(state): State<GlobalState>,
|
2026-02-07 04:02:38 +01:00
|
|
|
CurrentUserId(user_id): CurrentUserId,
|
2025-03-17 21:28:08 +01:00
|
|
|
Path(id): Path<String>,
|
|
|
|
|
) -> impl IntoResponse {
|
2026-02-03 17:59:04 +01:00
|
|
|
let dedup_service = &state.core.dedup_service;
|
|
|
|
|
|
|
|
|
|
// Get file info first to calculate content hash for dedup
|
|
|
|
|
let file_info = state.applications.file_service.get_file(&id).await.ok();
|
|
|
|
|
let content_hash: Option<String> = if file_info.is_some() {
|
|
|
|
|
// Try to read file content and calculate hash for dedup tracking
|
|
|
|
|
match state.applications.file_service.get_file_content(&id).await {
|
|
|
|
|
Ok(content) => {
|
|
|
|
|
let hash = crate::infrastructure::services::dedup_service::DedupService::hash_bytes(&content);
|
|
|
|
|
tracing::debug!("🔗 DEDUP: File {} has content hash: {}", id, &hash[..12]);
|
|
|
|
|
Some(hash)
|
|
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::debug!("Could not read file content for dedup: {}", e);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-26 18:33:22 +01:00
|
|
|
// Check if trash service is available
|
|
|
|
|
if let Some(trash_service) = &state.trash_service {
|
|
|
|
|
tracing::info!("Moving file to trash: {}", id);
|
|
|
|
|
|
|
|
|
|
// Debug logs to track trash components
|
|
|
|
|
tracing::debug!("Trash service type: {}", std::any::type_name_of_val(&*trash_service));
|
2026-02-07 04:02:38 +01:00
|
|
|
// User ID extracted from authenticated token via CurrentUserId
|
|
|
|
|
tracing::info!("Using authenticated user ID: {}", user_id);
|
2025-03-26 18:33:22 +01:00
|
|
|
|
|
|
|
|
// Try to move to trash first - add more detailed logging
|
|
|
|
|
tracing::info!("About to call trash_service.move_to_trash with id={}, type=file", id);
|
2026-02-07 04:02:38 +01:00
|
|
|
match trash_service.move_to_trash(&id, "file", &user_id).await {
|
2025-03-26 18:33:22 +01:00
|
|
|
Ok(_) => {
|
|
|
|
|
tracing::info!("File successfully moved to trash: {}", id);
|
2026-02-03 17:59:04 +01:00
|
|
|
|
|
|
|
|
// Decrement dedup reference count (file is in trash but content might be shared)
|
|
|
|
|
if let Some(hash) = &content_hash {
|
|
|
|
|
match dedup_service.remove_reference(hash).await {
|
|
|
|
|
Ok(deleted) => {
|
|
|
|
|
if deleted {
|
|
|
|
|
tracing::info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]);
|
|
|
|
|
} else {
|
|
|
|
|
tracing::debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("⚠️ DEDUP: Failed to decrement reference: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-26 18:33:22 +01:00
|
|
|
// Note: Use 204 No Content for consistency with DELETE operations
|
|
|
|
|
return StatusCode::NO_CONTENT.into_response();
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::error!("Could not move file to trash: {:?}", err);
|
|
|
|
|
tracing::error!("Error kind: {:?}, Error details: {}", err.kind, err);
|
|
|
|
|
tracing::warn!("Could not move file to trash, falling back to permanent delete: {}", err);
|
|
|
|
|
// Fall through to regular delete if trash fails
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
tracing::warn!("Trash service not available, using permanent delete");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to permanent delete if trash is unavailable or failed
|
|
|
|
|
tracing::warn!("Falling back to permanent delete for file: {}", id);
|
|
|
|
|
let file_service = &state.applications.file_service;
|
|
|
|
|
match file_service.delete_file(&id).await {
|
2025-03-23 22:44:18 +01:00
|
|
|
Ok(_) => {
|
2025-03-26 18:33:22 +01:00
|
|
|
tracing::info!("File permanently deleted: {}", id);
|
2026-02-03 17:59:04 +01:00
|
|
|
|
|
|
|
|
// Decrement dedup reference count for permanent delete
|
|
|
|
|
if let Some(hash) = &content_hash {
|
|
|
|
|
match dedup_service.remove_reference(hash).await {
|
|
|
|
|
Ok(deleted) => {
|
|
|
|
|
if deleted {
|
|
|
|
|
tracing::info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]);
|
|
|
|
|
} else {
|
|
|
|
|
tracing::debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("⚠️ DEDUP: Failed to decrement reference: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-26 18:33:22 +01:00
|
|
|
// CRITICAL FIX: Return status code that matches the API expectations (204 No Content)
|
|
|
|
|
// This ensures the client knows the operation was successful
|
2025-03-23 22:44:18 +01:00
|
|
|
StatusCode::NO_CONTENT.into_response()
|
|
|
|
|
},
|
2025-03-17 21:28:08 +01:00
|
|
|
Err(err) => {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::error!("Error deleting file: {}", err);
|
|
|
|
|
|
2025-03-26 18:33:22 +01:00
|
|
|
let status = match err.kind {
|
|
|
|
|
crate::common::errors::ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
2025-03-17 21:28:08 +01:00
|
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
(status, Json(serde_json::json!({
|
2025-03-23 22:44:18 +01:00
|
|
|
"error": format!("Error deleting file: {}", err)
|
2025-03-17 21:28:08 +01:00
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Moves a file to a different folder
|
|
|
|
|
pub async fn move_file(
|
2025-03-26 18:33:22 +01:00
|
|
|
State(service): State<FileServiceState>,
|
2025-03-17 21:28:08 +01:00
|
|
|
Path(id): Path<String>,
|
|
|
|
|
Json(payload): Json<MoveFilePayload>,
|
|
|
|
|
) -> impl IntoResponse {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::info!("API request: Moving file with ID: {} to folder: {:?}", id, payload.folder_id);
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2025-03-23 22:44:18 +01:00
|
|
|
// First verify if the file exists
|
2025-03-17 21:28:08 +01:00
|
|
|
match service.get_file(&id).await {
|
|
|
|
|
Ok(file) => {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::info!("File found: {} (ID: {}), proceeding with move operation", file.name, id);
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2025-03-23 22:44:18 +01:00
|
|
|
// For target folders, we trust that the move operation will verify their existence
|
2025-03-17 21:28:08 +01:00
|
|
|
if let Some(folder_id) = &payload.folder_id {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::info!("Will attempt to move to folder: {}", folder_id);
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
|
|
|
|
|
2025-03-23 22:44:18 +01:00
|
|
|
// Proceed with the move operation
|
2025-03-17 21:28:08 +01:00
|
|
|
match service.move_file(&id, payload.folder_id).await {
|
|
|
|
|
Ok(file) => {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::info!("File moved successfully: {} (ID: {})", file.name, file.id);
|
2025-03-17 21:28:08 +01:00
|
|
|
(StatusCode::OK, Json(file)).into_response()
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
2025-03-26 18:33:22 +01:00
|
|
|
// Simplify error handling
|
|
|
|
|
let status = StatusCode::INTERNAL_SERVER_ERROR;
|
|
|
|
|
tracing::error!("Error moving file: {}", err);
|
2025-03-17 21:28:08 +01:00
|
|
|
|
|
|
|
|
(status, Json(serde_json::json!({
|
2025-03-26 18:33:22 +01:00
|
|
|
"error": format!("Error moving file: {}", err)
|
2025-03-17 21:28:08 +01:00
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(err) => {
|
2025-03-23 22:44:18 +01:00
|
|
|
tracing::error!("Error finding file to move - does not exist: {} (ID: {})", err, id);
|
2025-03-17 21:28:08 +01:00
|
|
|
(StatusCode::NOT_FOUND, Json(serde_json::json!({
|
2025-03-23 22:44:18 +01:00
|
|
|
"error": format!("The file with ID: {} does not exist", id),
|
2025-03-17 21:28:08 +01:00
|
|
|
"code": StatusCode::NOT_FOUND.as_u16()
|
|
|
|
|
}))).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Payload for moving a file
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct MoveFilePayload {
|
|
|
|
|
/// Target folder ID (None means root)
|
|
|
|
|
pub folder_id: Option<String>,
|
|
|
|
|
}
|