Files
Oxicloud/src/interfaces/api/handlers/file_handler.rs
T
Diocrafts 06ed0455ce perf: migrate all user/session/auth IDs from VARCHAR(36) to native UUID
- Schema: all ~15 VARCHAR(36) columns → UUID with DEFAULT gen_random_uuid()
- Domain entities: User, Session, DeviceCode, AppPassword, Share → id: Uuid
- DTOs: CurrentUser.id → Uuid (API boundary DTOs keep String for JSON)
- Auth middleware: parse JWT claims.sub (String) → Uuid at boundary
- All repository traits, port traits, service impls updated end-to-end
- Handlers: pass Uuid by value (Copy, 16 bytes) instead of String refs
- Settings chain: updated_by column → Uuid (was text, caused setup crash)
- Removed ~650 lines of String↔Uuid conversion boilerplate
- Eliminates per-request heap allocations for ID cloning
- 16-byte binary comparison vs 36-byte string comparison in all queries
- Native UUID indexing in PostgreSQL (btree on 16 bytes vs 36-char text)

85 files changed, 1090 insertions(+), 1739 deletions(-)
2026-03-07 14:59:32 +01:00

906 lines
39 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
Json,
body::Body,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
};
use bytes::Bytes;
use http_range_header::parse_range_header;
use serde::Deserialize;
use std::collections::HashMap;
use crate::application::ports::file_ports::OptimizedFileContent;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
/**
* Type aliases for dependency injection state.
*/
/// Global application state for dependency injection
type GlobalState = Arc<AppState>;
/**
* API handler for file-related operations.
*
* Acts as a thin HTTP adapter in the hexagonal architecture: it parses requests,
* delegates business logic to application services, and maps results to HTTP
* responses. No infrastructure or strategy logic lives here.
*/
pub struct FileHandler;
impl FileHandler {
// ═══════════════════════════════════════════════════════════════════════
// UPLOAD
// ═══════════════════════════════════════════════════════════════════════
/// Streaming file upload — constant ~64 KB RAM regardless of file size.
///
/// **Hash-on-Write**: BLAKE3 is computed while spooling the multipart
/// body to the temp file. This eliminates the second sequential read
/// that dedup_service would otherwise need, cutting total I/O in half.
pub async fn upload_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
multipart: Multipart,
) -> impl IntoResponse {
match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok((file, _blob_hash)) => 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 `(FileDto, blob_hash)` on success. The blob hash is the
/// BLAKE3 digest computed during the hash-on-write spool and is
/// propagated without an extra database round-trip so that callers
/// (e.g. thumbnail generation) can resolve the physical blob path
/// immediately.
async fn upload_file_inner(
state: &GlobalState,
auth_user: &AuthUser,
mut multipart: Multipart,
) -> Result<(crate::application::dtos::file_dto::FileDto, String), Response<Body>> {
let upload_service = &state.applications.file_upload_service;
let mut folder_id: Option<String> = None;
tracing::debug!("📤 Processing streaming file upload (hash-on-write)");
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
if name == "folder_id" {
let v = field.text().await.unwrap_or_default();
if !v.is_empty() {
folder_id = Some(v);
}
continue;
}
if name == "file" {
let 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();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ──
if let Some(ref fid) = folder_id {
use crate::application::ports::inbound::FolderUseCase;
let folder_service = &state.applications.folder_service;
if folder_service.get_folder_owned(fid, auth_user.id).await.is_err() {
tracing::warn!(
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
auth_user.username,
fid,
);
return Err(Self::domain_error_response(
crate::common::errors::DomainError::not_found("Folder", fid),
));
}
}
// ── 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)
.await
{
tracing::warn!(
"⛔ UPLOAD REJECTED (early quota): user={}, file={}, est_size={}",
auth_user.username,
filename,
estimated_size
);
return Err(Self::quota_error_response(err));
}
}
// ── Spool multipart field to temp file + hash-on-write ──
// .dedup_temp is created once by DedupService::initialize() at startup
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()));
let mut total_size: u64 = 0;
let mut hasher = blake3::Hasher::new();
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 {
let _ = file.set_len(len).await; // best-effort
}
// 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;
// IMPORTANT: use explicit match instead of `while let Ok(Some(..))`.
// The old pattern silently swallowed Err (client disconnect)
// and accepted partially received data as a complete upload.
loop {
match field.chunk().await {
Ok(Some(chunk)) => {
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))?;
}
Ok(None) => break, // End of field — upload complete
Err(e) => {
return Err(format!(
"Connection lost during upload (received {} bytes): {}",
total_size, e
));
}
}
}
tokio::io::AsyncWriteExt::flush(&mut writer)
.await
.map_err(|e| format!("Failed to flush temp file: {}", e))?;
Ok(())
}
.await;
if let Err(e) = spool_result {
let _ = tokio::fs::remove_file(&temp_path).await;
tracing::error!("❌ UPLOAD SPOOL FAILED: {} - {}", filename, e);
return Err(Self::domain_error_response(
crate::common::errors::DomainError::internal_error("FileUpload", e),
));
}
// Empty file — use streaming path with the (empty) temp file
if total_size == 0 {
let hash = hasher.finalize().to_hex().to_string();
let dto = upload_service
.upload_file_streaming(
filename,
folder_id,
content_type,
&temp_path,
0,
Some(hash.clone()),
)
.await
.map_err(Self::domain_error_response)?;
return Ok((dto, hash));
}
// Finalize hash
let hash = hasher.finalize().to_hex().to_string();
// ── MIME detection (magic bytes + extension fallback) ─
let content_type = crate::common::mime_detect::refine_content_type_from_file(
&temp_path,
&filename,
&content_type,
)
.await;
// ── 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)
.await
{
let _ = tokio::fs::remove_file(&temp_path).await;
tracing::warn!(
"⛔ UPLOAD REJECTED (quota): user={}, file={}, size={}",
auth_user.username,
filename,
total_size
);
return Err(Self::quota_error_response(err));
}
// ── Streaming upload (temp file → blob store, hash pre-computed) ─
match upload_service
.upload_file_streaming(
filename.clone(),
folder_id,
content_type,
&temp_path,
total_size,
Some(hash.clone()),
)
.await
{
Ok(file) => {
tracing::info!(
"✅ STREAMING UPLOAD: {} ({} bytes, ID: {})",
filename,
total_size,
file.id
);
return Ok((file, hash));
}
Err(err) => {
let _ = tokio::fs::remove_file(&temp_path).await;
tracing::error!("❌ UPLOAD FAILED: {} - {}", filename, err);
return Err(Self::domain_error_response(err));
}
}
}
}
Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "No file provided"
})),
)
.into_response())
}
// ═══════════════════════════════════════════════════════════════════════
// THUMBNAILS
// ═══════════════════════════════════════════════════════════════════════
/// Get a thumbnail for an image file.
///
/// Thumbnail orchestration (path resolution, generation, caching) stays here
/// because it is tightly coupled to HTTP response headers.
pub async fn get_thumbnail(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path((id, size)): Path<(String, String)>,
) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize;
let file_retrieval_service = &state.applications.file_retrieval_service;
let thumbnail_service = &state.core.thumbnail_service;
let thumb_size = match size.as_str() {
"icon" => ThumbnailSize::Icon,
"preview" => ThumbnailSize::Preview,
"large" => ThumbnailSize::Large,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid thumbnail size. Use: icon, preview, or large"
})),
)
.into_response();
}
};
let file = match file_retrieval_service
.get_file_owned(&id, auth_user.id)
.await
{
Ok(f) => f,
Err(err) => {
return AppError::from(err).into_response();
}
};
if !thumbnail_service.is_supported_image(&file.mime_type) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "File is not a supported image type"
})),
)
.into_response();
}
// Resolve the physical blob path (content-addressable storage)
let blob_hash = match state
.repositories
.file_read_repository
.get_blob_hash(&id)
.await
{
Ok(hash) => hash,
Err(_) => {
return AppError::internal_error("File blob not found").into_response();
}
};
let file_path = state.core.dedup_service.blob_path(&blob_hash);
match thumbnail_service
.get_thumbnail(&id, thumb_size.into(), &file_path)
.await
{
Ok(data) => {
let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap()
.into_response()
}
Err(err) => {
AppError::internal_error(format!("Thumbnail generation failed: {}", err))
.into_response()
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// DOWNLOAD
// ═══════════════════════════════════════════════════════════════════════
/// Downloads a file with optimized multi-tier strategy.
///
/// The tier selection (write-behind → hot cache → WebP transcode → mmap →
/// streaming) is fully handled by `FileRetrievalUseCase::get_file_optimized`.
/// This handler only deals with HTTP concerns: ETag, Range, Content-Disposition,
/// and optional compression.
pub async fn download_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> impl IntoResponse {
let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata (ownership-scoped) ────────────────────────
let file_dto = match retrieval.get_file_owned(&id, auth_user.id).await {
Ok(f) => f,
Err(err) => {
return AppError::from(err).into_response();
}
};
// ── Metadata-only request ────────────────────────────────────
if params
.get("metadata")
.is_some_and(|v| v == "true" || v == "1")
{
return (
StatusCode::OK,
Json(serde_json::json!({
"id": file_dto.id,
"name": file_dto.name,
"path": file_dto.path,
"size": file_dto.size,
"mime_type": file_dto.mime_type,
"folder_id": file_dto.folder_id,
"created_at": file_dto.created_at,
"modified_at": file_dto.modified_at
})),
)
.into_response();
}
let etag = format!("\"{}-{}\"", id, file_dto.modified_at);
// ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& (client_etag == etag || client_etag == "*")
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.body(Body::empty())
.unwrap()
.into_response();
}
// ── Range Requests ───────────────────────────────────────────
if let Some(range_header) = headers.get(header::RANGE)
&& let Ok(range_str) = range_header.to_str()
&& let Ok(ranges) = parse_range_header(range_str)
{
let validated = ranges.validate(file_dto.size);
if let Ok(valid_ranges) = validated {
if let Some(range) = valid_ranges.first() {
let start = *range.start();
let end = *range.end();
let range_length = end - start + 1;
let disposition =
Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
match retrieval
.get_file_range_stream_owned(&id, auth_user.id, start, Some(end + 1))
.await
{
Ok(stream) => {
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file_dto.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.body(Body::from_stream(Box::into_pin(stream)))
.unwrap()
.into_response();
}
Err(err) => {
tracing::error!("Error creating range stream: {}", err);
// fall through to normal download
}
}
}
} else {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size))
.body(Body::empty())
.unwrap()
.into_response();
}
}
// ── Normal download (delegated to service) ───────────────────
let disposition = Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
let accept_webp = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("image/webp"));
let prefer_original = params
.get("original")
.is_some_and(|v| v == "true" || v == "1");
// Use the ownership-scoped optimized download.
// Ownership was already verified by get_file_owned above,
// so we can safely use the preloaded variant.
match retrieval
.get_file_optimized_preloaded(&id, file_dto.clone(), accept_webp, prefer_original)
.await
{
Ok((_file, content)) => match content {
OptimizedFileContent::Bytes {
data, mime_type, ..
} => Self::build_cached_response(data, &mime_type, &disposition, &etag)
.into_response(),
OptimizedFileContent::Mmap(mmap_data) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, mmap_data.len())
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from(mmap_data))
.unwrap()
.into_response(),
OptimizedFileContent::Stream(pinned_stream) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, file_dto.size)
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(pinned_stream))
.unwrap()
.into_response(),
},
Err(err) => {
AppError::from(err).into_response()
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// LIST
// ═══════════════════════════════════════════════════════════════════════
/// Lists files, extracting `folder_id` from query parameters.
///
/// Axum-compatible handler wrapper around [`Self::list_files`].
pub async fn list_files_query(
State(state): State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
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_owned(folder_id, auth_user.id).await {
Ok(files) => {
// 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();
}
tracing::info!("Found {} files", files.len());
let mut resp = (StatusCode::OK, Json(files)).into_response();
resp.headers_mut()
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
resp
}
Err(err) => {
AppError::from(err).into_response()
}
}
}
/// Uploads a file and generates thumbnails in the background for images.
///
/// Delegates to [`Self::upload_file_inner`] and, on success, spawns
/// a background task to generate all thumbnail sizes before serialising
/// the `FileDto` once.
pub async fn upload_file_with_thumbnails(
State(state): State<GlobalState>,
auth_user: AuthUser,
multipart: Multipart,
) -> impl IntoResponse {
let (file, blob_hash) = match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok(pair) => pair,
Err(response) => return response.into_response(),
};
// Generate thumbnails for supported images in background.
// The blob_hash was already computed during the hash-on-write spool,
// so we resolve the physical path directly — no extra DB round-trip.
if state
.core
.thumbnail_service
.is_supported_image(&file.mime_type)
{
let file_id = file.id.clone();
let thumbnail_service = state.core.thumbnail_service.clone();
let file_path = state.core.dedup_service.blob_path(&blob_hash);
tokio::spawn(async move {
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
thumbnail_service
.generate_all_sizes_background(file_id, file_path);
});
}
Self::created_json_response(&file).into_response()
}
// ═══════════════════════════════════════════════════════════════════════
// METADATA
// ═══════════════════════════════════════════════════════════════════════
/// Returns EXIF/media metadata for a file.
///
/// Used by the Photos lightbox and for testing EXIF extraction.
pub async fn get_file_metadata(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> impl IntoResponse {
// Verify ownership
let file_read = &state.repositories.file_read_repository;
if let Err(e) = file_read.verify_file_owner(&file_id, auth_user.id).await {
let msg = e.to_string();
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": msg })),
)
.into_response();
}
let metadata_repo = &state.repositories.file_metadata_repository;
match metadata_repo.get(&file_id).await {
Ok(Some(meta)) => (StatusCode::OK, Json(meta)).into_response(),
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({
"file_id": file_id,
"message": "No EXIF metadata available"
})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response(),
}
}
// ═══════════════════════════════════════════════════════════════════════
// DELETE
// ═══════════════════════════════════════════════════════════════════════
/// Deletes a file (trash-first with dedup cleanup).
///
/// All logic (trash fallback, dedup ref-count, hash computation) is handled
/// by `FileManagementUseCase::delete_with_cleanup`.
///
/// When auth is available, uses trash-first deletion; otherwise falls back
/// to permanent delete so the endpoint works with or without auth.
pub async fn delete_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let mgmt = &state.applications.file_management_service;
// Auth required: trash-first with dedup cleanup + ownership verification
let result = mgmt
.delete_with_cleanup(&id, auth_user.id)
.await
.map(|was_trashed| {
if was_trashed {
tracing::info!("File moved to trash: {}", id);
} else {
tracing::info!("File permanently deleted: {}", id);
}
});
match result {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => AppError::from(err).into_response()
}
}
// ═══════════════════════════════════════════════════════════════════════
// MOVE
// ═══════════════════════════════════════════════════════════════════════
/// Renames a file (ownership-verified)
pub async fn rename_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(payload): Json<serde_json::Value>,
) -> impl IntoResponse {
let new_name = match payload.get("name").and_then(|v| v.as_str()) {
Some(name) if !name.trim().is_empty() => name.trim().to_string(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Missing or empty 'name' field"
})),
)
.into_response();
}
};
tracing::info!("Renaming file {} to \"{}\"", id, new_name);
let mgmt = &state.applications.file_management_service;
match mgmt.rename_file_owned(&id, auth_user.id, &new_name).await {
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => AppError::from(err).into_response()
}
}
/// Moves a file to a different folder (ownership-verified)
pub async fn move_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(payload): Json<MoveFilePayload>,
) -> impl IntoResponse {
tracing::info!("Moving file {} to folder {:?}", id, payload.folder_id);
let mgmt = &state.applications.file_management_service;
match mgmt
.move_file_owned(&id, auth_user.id, payload.folder_id)
.await
{
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
Err(err) => AppError::from(err).into_response()
}
}
/// Moves a file to a different folder (simplified payload, ownership-verified)
pub async fn move_file_simple(
State(state): State<GlobalState>,
auth_user: AuthUser,
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_owned(&id, auth_user.id, folder_id).await {
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => AppError::from(err).into_response()
}
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE HELPERS
// ═══════════════════════════════════════════════════════════════════════
/// Build a Content-Disposition header value.
///
/// Uses RFC 5987 `filename*=UTF-8''<percent-encoded>` to safely handle
/// filenames with quotes, non-ASCII characters, or other special chars.
/// A sanitised ASCII `filename=` fallback is included for legacy clients.
fn content_disposition(name: &str, mime: &str, params: &HashMap<String, String>) -> String {
let force_inline = params
.get("inline")
.is_some_and(|v| v == "true" || v == "1");
let disposition = if force_inline
|| mime.starts_with("image/")
|| mime == "application/pdf"
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
{
"inline"
} else {
"attachment"
};
// RFC 5987 percent-encode for filename* (attr-char safe set)
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
// Characters that DON'T need encoding per RFC 5987 attr-char:
// ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "." /
// "^" / "_" / "`" / "|" / "~"
const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'!')
.remove(b'#')
.remove(b'$')
.remove(b'&')
.remove(b'+')
.remove(b'-')
.remove(b'.')
.remove(b'^')
.remove(b'_')
.remove(b'`')
.remove(b'|')
.remove(b'~');
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
// ASCII fallback: strip anything outside printable ASCII and
// replace '"' and '\\' to prevent header injection.
let ascii_safe: String = name
.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.map(|c| match c {
'"' | '\\' => '_',
_ => c,
})
.collect();
format!(
"{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}"
)
}
/// 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> {
AppError::from(err).into_response()
}
/// Build a quota-specific error response with 507 status and structured body.
fn quota_error_response(err: crate::common::errors::DomainError) -> Response<Body> {
AppError::from(err).into_response()
}
/// 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(
content: Bytes,
mime_type: &str,
disposition: &str,
etag: &str,
) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::ETAG, etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::VARY, "Accept-Encoding")
.header(header::CONTENT_LENGTH, content.len())
.body(Body::from(content))
.unwrap()
}
}
/// Payload for moving a file
#[derive(Debug, Deserialize)]
pub struct MoveFilePayload {
/// Target folder ID (None means root)
pub folder_id: Option<String>,
}