use axum::{ Json, body::Body, extract::{Path, Query, State}, http::{HeaderMap, Response, StatusCode, header}, response::IntoResponse, }; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio_util::io::ReaderStream; use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; type AppState = Arc; /// Handler for folder-related API endpoints pub struct FolderHandler; impl FolderHandler { /// Creates a new folder. /// When parent_id is not provided, the folder is created inside the /// authenticated user's home folder rather than at the storage root. pub async fn create_folder( State(service): State, auth_user: AuthUser, Json(mut dto): Json, ) -> impl IntoResponse { // If no parent_id was supplied, resolve the user's home folder as // the default parent so the new folder is nested correctly. if dto.parent_id.is_none() { tracing::info!( "create_folder: parent_id is None for user '{}', resolving home folder", auth_user.username ); match service.list_folders_for_owner(None, &auth_user.id).await { Ok(folders) => { if let Some(home) = folders.first() { tracing::info!( "create_folder: resolved home folder ID '{}' for user '{}'", home.id, auth_user.username ); dto.parent_id = Some(home.id.clone()); } else { tracing::warn!( "create_folder: home folder not found for user '{}', folder will be created at root", auth_user.username ); } } Err(e) => { tracing::error!( "create_folder: failed to list folders for home resolution: {}", e ); } } } // ── SECURITY: Verify parent folder ownership (IDOR V-04 fix) ── if let Some(ref parent_id) = dto.parent_id { use crate::application::ports::inbound::FolderUseCase; if service .get_folder_owned(parent_id, &auth_user.id) .await .is_err() { tracing::warn!( "create_folder: user '{}' attempted to create folder in parent '{}' owned by another user", auth_user.username, parent_id, ); return AppError::not_found(format!("Parent folder not found: {}", parent_id)) .into_response(); } } match service.create_folder(dto).await { Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } } /// Gets a folder by ID. /// Validates that the authenticated user owns the folder. pub async fn get_folder( State(service): State, auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { match service.get_folder(&id).await { Ok(folder) => { // Access check: folder must belong to the requesting user if let Some(ref owner) = folder.owner_id && owner != &auth_user.id { tracing::warn!( "get_folder: user '{}' attempted to access folder '{}' owned by '{}'", auth_user.id, id, owner ); return AppError::not_found("Folder not found").into_response(); } (StatusCode::OK, Json(folder)).into_response() } Err(err) => AppError::from(err).into_response(), } } /// Lists root folders for the authenticated user. /// Only returns folders owned by this user — no information disclosure. pub async fn list_root_folders( State(service): State, auth_user: AuthUser, ) -> axum::response::Response { Self::list_folders_scoped(service, None, &auth_user).await } /// Lists contents of a specific folder by its ID. /// Scoped to the authenticated user's folders. pub async fn list_folder_contents( State(service): State, auth_user: AuthUser, Path(id): Path, ) -> axum::response::Response { Self::list_folders_scoped(service, Some(&id), &auth_user).await } /// Lists root folders with pagination support. pub async fn list_root_folders_paginated( State(service): State, auth_user: AuthUser, _pagination: Query, ) -> axum::response::Response { Self::list_folders_scoped(service, None, &auth_user).await } /// Lists contents of a specific folder with pagination. /// Scoped to the authenticated user — only returns folders owned by this user. pub async fn list_folder_contents_paginated( State(service): State, auth_user: AuthUser, Path(id): Path, pagination: Query, ) -> axum::response::Response { match service .list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination) .await { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => AppError::from(err).into_response(), } } /// Internal helper: lists folders scoped to the authenticated user. /// Uses `list_folders_for_owner` — the DB query filters by `user_id`, /// so no data from other users ever leaves the database. async fn list_folders_scoped( service: AppState, parent_id: Option<&str>, auth_user: &AuthUser, ) -> axum::response::Response { match service .list_folders_for_owner(parent_id, &auth_user.id) .await { Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), Err(err) => AppError::from(err).into_response(), } } /// Compute a lightweight ETag from the maximum `modified_at` timestamp /// and item count. No body buffering required. fn compute_listing_etag( folders: &[crate::application::dtos::folder_dto::FolderDto], files: &[crate::application::dtos::file_dto::FileDto], ) -> String { let max_mod = folders .iter() .map(|f| f.modified_at) .chain(files.iter().map(|f| f.modified_at)) .max() .unwrap_or(0); let count = folders.len() + files.len(); let mut hasher = std::collections::hash_map::DefaultHasher::new(); max_mod.hash(&mut hasher); count.hash(&mut hasher); format!("\"{:x}\"", hasher.finish()) } /// Returns both sub-folders and files for a given folder in a single /// response, eliminating the double-fetch the frontend used to make. /// /// Both queries run concurrently via `tokio::join!`. /// Supports `If-None-Match` / ETag for conditional responses (304). pub async fn list_folder_listing( State(state): State>, auth_user: AuthUser, headers: HeaderMap, Path(id): Path, ) -> axum::response::Response { let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; // Run both queries concurrently — no sequential wait. let (folders_result, files_result) = tokio::join!( folder_service.list_folders_for_owner(Some(&id), &auth_user.id), file_service.list_files_owned(Some(&id), &auth_user.id) ); match (folders_result, files_result) { (Ok(folders), Ok(files)) => { let etag = Self::compute_listing_etag(&folders, &files); // 304 Not Modified if the 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(); } let listing = FolderListingDto { folders, files }; let mut resp = (StatusCode::OK, Json(listing)).into_response(); resp.headers_mut() .insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap()); resp } (Err(err), _) | (_, Err(err)) => AppError::from(err).into_response(), } } /// Renames a folder (ownership enforced by service layer) pub async fn rename_folder( State(service): State, auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { match service.rename_folder(&id, dto, &auth_user.id).await { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } } /// Moves a folder to a new parent (ownership enforced by service layer) pub async fn move_folder( State(service): State, auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { match service.move_folder(&id, dto, &auth_user.id).await { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } } /// Deletes a folder (ownership enforced by service layer) pub async fn delete_folder( State(service): State, auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { match service.delete_folder(&id, &auth_user.id).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(err) => AppError::from(err).into_response(), } } /// Deletes a folder with trash functionality (ownership enforced by service layer) pub async fn delete_folder_with_trash( State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { let user_id = &auth_user.id; // Check if trash service is available if let Some(trash_service) = &state.trash_service { tracing::info!("Moving folder to trash: {}", id); // Try to move to trash first match trash_service.move_to_trash(&id, "folder", user_id).await { Ok(_) => { tracing::info!("Folder successfully moved to trash: {}", id); return StatusCode::NO_CONTENT.into_response(); } Err(err) => { tracing::warn!( "Could not move folder to trash, falling back to permanent delete: {}", err ); // Fall through to regular delete if trash fails } } } // Fallback to permanent delete if trash is unavailable or failed let folder_service = &state.applications.folder_service; match folder_service.delete_folder(&id, user_id).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() } Err(err) => AppError::from(err).into_response(), } } /// Downloads a folder as a ZIP file (ownership enforced) pub async fn download_folder_zip( State(state): State>, auth_user: AuthUser, Path(id): Path, Query(_params): Query>, ) -> impl IntoResponse { tracing::info!("Downloading folder as ZIP: {}", id); // Get folder information and verify ownership let folder_service = &state.applications.folder_service; match folder_service.get_folder(&id).await { Ok(folder) => { // Access check: folder must belong to the requesting user if folder.owner_id.as_deref() != Some(&auth_user.id) { tracing::warn!( "download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'", auth_user.id, id, folder.owner_id ); return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Folder not found" })), ) .into_response(); } tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); // Use ZIP service from DI container let zip_service = match &state.core.zip_service { Some(svc) => svc, None => { tracing::error!("ZipService not initialized"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "ZipService not initialized" })), ) .into_response(); } }; // Create the ZIP archive (written to a temp file, O(1) RAM) match zip_service.create_folder_zip(&id, &folder.name).await { Ok(temp_file) => { // Get the file size for Content-Length let file_size = match temp_file.as_file().metadata() { Ok(m) => m.len(), Err(e) => { tracing::error!("Error reading temp file metadata: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Error creating ZIP file" })), ) .into_response(); } }; tracing::info!("ZIP file created successfully, size: {} bytes", file_size); // Split the NamedTempFile into the already-open std File // and the TempPath (auto-deletes on drop). This reuses // the existing fd instead of opening a second one. let (std_file, temp_path) = temp_file.into_parts(); let tokio_file = tokio::fs::File::from_std(std_file); // Stream the file to the client in chunks let stream = ReaderStream::new(tokio_file); let body = axum::body::Body::from_stream(stream); // Setup headers for download let filename = format!("{}.zip", folder.name); let content_disposition = format!("attachment; filename=\"{}\"", filename); let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/zip") .header(header::CONTENT_DISPOSITION, content_disposition) .header(header::CONTENT_LENGTH, file_size) .body(body) .unwrap(); // Keep TempPath alive in the response extensions so the // file is only deleted AFTER the body stream finishes. response.extensions_mut().insert(Arc::new(temp_path)); response.into_response() } Err(err) => { tracing::error!("Error creating ZIP file: {}", err); AppError::internal_error(format!("Error creating ZIP file: {}", err)) .into_response() } } } Err(err) => { tracing::error!("Folder not found: {}", err); AppError::from(err).into_response() } } } }