Files
Oxicloud/src/interfaces/api/handlers/folder_handler.rs
T
2026-06-06 18:49:14 +02:00

763 lines
30 KiB
Rust

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::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::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<FolderService>;
/// Handler for folder-related API endpoints
pub struct FolderHandler;
impl FolderHandler {
// ── Why no #[utoipa::path] here? ─────────────────────────────────────────────
// utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion.
// Rust allows struct definitions at module scope but forbids them inside impl blocks,
// so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP
// verb or annotation content. All route handlers are free functions below.
// TODO: collapse after utoipa upgrade.
/// 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(super) async fn create_folder_impl(
State(service): State<AppState>,
auth_user: AuthUser,
Json(mut dto): Json<CreateFolderDto>,
) -> 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_with_perms(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
);
}
}
}
match service.create_folder_with_perms(dto, auth_user.id).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(super) async fn get_folder_impl(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => (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(super) async fn list_root_folders_impl(
State(service): State<AppState>,
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(super) async fn list_folder_contents_impl(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> axum::response::Response {
Self::list_folders_scoped(service, Some(&id), &auth_user).await
}
/// Lists root folders with pagination.
/// Scoped to the authenticated user — only returns folders owned by this user.
pub(super) async fn list_root_folders_paginated_impl(
State(service): State<AppState>,
auth_user: AuthUser,
_pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
Self::list_folders_scoped(service, None, &auth_user).await
}
/// Lists sub-folders inside a folder with pagination.
pub(super) async fn list_folder_contents_paginated_impl(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
match service
.list_folders_paginated_with_perms(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_with_perms(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(super) async fn list_folder_listing_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
headers: HeaderMap,
Path(id): Path<String>,
) -> 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_with_perms(Some(&id), auth_user.id),
file_service.list_files_with_perms(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).
pub(super) async fn rename_folder_impl(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse {
match service
.rename_folder_with_perms(&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).
pub(super) async fn move_folder_impl(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse {
match service.move_folder_with_perms(&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<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
match service.delete_folder_with_perms(&id, auth_user.id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
/// Deletes a folder (moves to trash if enabled, otherwise permanent).
pub(super) async fn delete_folder_with_trash_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let user_id = auth_user.id;
// Check if trash service is available
// FIXME: permissions !!
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_with_perms(&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 and all its contents as a ZIP archive.
pub(super) async fn download_folder_zip_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Query(_params): Query<HashMap<String, String>>,
) -> 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_with_perms(&id, auth_user.id)
.await
{
Ok(folder) => {
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()
}
}
}
}
// ── Route handlers (free functions) ──────────────────────────────────────────
//
// All annotated route functions live here rather than as methods on FolderHandler
// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside
// its expansion. Rust allows struct definitions at module scope but forbids them
// inside impl blocks — so every #[utoipa::path] annotation on a FolderHandler
// method fails to compile regardless of HTTP verb or annotation content.
//
// All logic lives in the FolderHandler::*_impl methods above; these thin wrappers
// exist solely to carry the OpenAPI annotation at a scope where utoipa can
// generate its helper types.
//
// routes.rs calls these free functions directly.
// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue.
#[utoipa::path(
post,
path = "/api/folders",
request_body(content = CreateFolderDto, content_type = "application/json", description = "Folder creation payload"),
responses(
(status = 201, description = "Folder created", body = FolderDto),
(status = 400, description = "Invalid request"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn create_folder(
state: State<AppState>,
auth_user: AuthUser,
json: Json<CreateFolderDto>,
) -> impl IntoResponse {
FolderHandler::create_folder_impl(state, auth_user, json).await
}
#[utoipa::path(
get,
path = "/api/folders/{id}",
params(("id" = String, Path, description = "Folder ID")),
responses(
(status = 200, description = "Folder", body = FolderDto),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn get_folder(
state: State<AppState>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
FolderHandler::get_folder_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders",
responses(
(status = 200, description = "List of root folders", body = Vec<FolderDto>),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn list_root_folders(
state: State<AppState>,
auth_user: AuthUser,
) -> axum::response::Response {
FolderHandler::list_root_folders_impl(state, auth_user).await
}
#[deprecated = "Use /api/folders/{id}/resources instead"]
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(("id" = String, Path, description = "Folder ID")),
responses(
(status = 200, description = "List of sub-folders", body = Vec<FolderDto>),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
#[allow(deprecated)]
pub async fn list_folder_contents(
state: State<AppState>,
auth_user: AuthUser,
path: Path<String>,
) -> axum::response::Response {
tracing::warn!(
"Deprecated endpoint called: GET /api/folders/{{id}}/contents — use GET /api/folders/{{id}}/resources?resource_types=folder instead"
);
FolderHandler::list_folder_contents_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders/paginated",
params(PaginationRequestDto),
responses(
(status = 200, description = "Paginated list of root folders"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn list_root_folders_paginated(
state: State<AppState>,
auth_user: AuthUser,
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
FolderHandler::list_root_folders_paginated_impl(state, auth_user, pagination).await
}
#[deprecated = "Use /api/folders/{id}/resources instead"]
#[utoipa::path(
get,
path = "/api/folders/{id}/contents/paginated",
params(
("id" = String, Path, description = "Folder ID"),
PaginationRequestDto,
),
responses(
(status = 200, description = "Paginated list of sub-folders"),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
#[allow(deprecated)]
pub async fn list_folder_contents_paginated(
state: State<AppState>,
auth_user: AuthUser,
path: Path<String>,
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
tracing::warn!(
"Deprecated endpoint called: GET /api/folders/{{id}}/contents/paginated — use GET /api/folders/{{id}}/resources instead"
);
FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await
}
#[deprecated = "Use /api/folders/{id}/resources instead"]
#[utoipa::path(
get,
path = "/api/folders/{id}/listing",
params(("id" = String, Path, description = "Folder ID")),
responses(
(status = 200, description = "Folder listing (sub-folders + files)", body = FolderListingDto),
(status = 304, description = "Not modified"),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
#[allow(deprecated)]
pub async fn list_folder_listing(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
headers: HeaderMap,
path: Path<String>,
) -> axum::response::Response {
FolderHandler::list_folder_listing_impl(state, auth_user, headers, path).await
}
#[utoipa::path(
put,
path = "/api/folders/{id}/rename",
params(("id" = String, Path, description = "Folder ID")),
request_body(content = RenameFolderDto, content_type = "application/json", description = "Rename payload"),
responses(
(status = 200, description = "Renamed folder", body = FolderDto),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn rename_folder(
state: State<AppState>,
auth_user: AuthUser,
path: Path<String>,
json: Json<RenameFolderDto>,
) -> impl IntoResponse {
FolderHandler::rename_folder_impl(state, auth_user, path, json).await
}
#[utoipa::path(
put,
path = "/api/folders/{id}/move",
params(("id" = String, Path, description = "Folder ID")),
request_body(content = MoveFolderDto, content_type = "application/json", description = "Move payload"),
responses(
(status = 200, description = "Moved folder", body = FolderDto),
(status = 404, description = "Folder or destination not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn move_folder(
state: State<AppState>,
auth_user: AuthUser,
path: Path<String>,
json: Json<MoveFolderDto>,
) -> impl IntoResponse {
FolderHandler::move_folder_impl(state, auth_user, path, json).await
}
#[utoipa::path(
delete,
path = "/api/folders/{id}",
params(("id" = String, Path, description = "Folder ID")),
responses(
(status = 204, description = "Folder deleted"),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn delete_folder_with_trash(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
FolderHandler::delete_folder_with_trash_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders/{id}/download",
params(("id" = String, Path, description = "Folder ID")),
responses(
(status = 200, description = "ZIP archive stream (application/zip)"),
(status = 404, description = "Folder not found"),
(status = 501, description = "ZIP service not available"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn download_folder_zip(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
query: Query<HashMap<String, String>>,
) -> impl IntoResponse {
FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await
}
// ── GET /api/folders/{id}/resources ─────────────────────────────────────────
#[utoipa::path(
get,
path = "/api/folders/{id}/resources",
params(
("id" = String, Path, description = "Folder ID"),
FolderResourcesQuery,
),
responses(
(status = 200,
description = "Cursor-paginated files and folders inside the requested folder. \
Items arrive in `order_by` order (folders first when order_by=name). \
`next_cursor` is absent on the last page.",
body = FolderResourcesDto),
(status = 404, description = "Folder not found or access denied"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn list_folder_resources(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
Query(q): Query<FolderResourcesQuery>,
) -> impl IntoResponse {
let order_by = q.order_by.clone().unwrap_or_else(|| "name".to_owned());
let kinds = q.resource_kinds();
let opts = ListResourcesOptions {
limit: q.limit_clamped(),
cursor: q.decode_cursor(),
order_by: &order_by,
kinds: kinds.as_deref(),
reverse: q.reverse,
};
match service
.list_resources_paged_with_perms(&id, auth_user.id, opts)
.await
{
Ok((rows, next_cursor)) => {
let items: Vec<FolderResourceItemDto> = rows
.into_iter()
.map(|row| {
if row.resource_type == "folder" {
let resource_id = row.id.to_string();
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path: String::new(), // cleared — share recipients must not see hierarchy
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
resource: ResourceContentDto::Folder(dto),
}
} else {
let mime = row
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// `FolderResourceRow` (the UNION ALL row used
// by this listing) doesn't carry `blob_hash`,
// so neither `content_hash` nor `etag` can be
// populated here without widening the SQL. The
// REST file-listing UI doesn't issue
// conditional requests against these rows —
// file download / WebDAV PROPFIND go through
// paths that DO carry the hash.
let dto = FileDto {
id: row.id.to_string(),
name: row.name.clone(),
path: String::new(),
size: size_bytes,
mime_type: Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
icon_class: Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)),
category: Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash: String::new(),
etag: String::new(),
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
resource: ResourceContentDto::File(dto),
}
}
})
.collect();
(
StatusCode::OK,
Json(FolderResourcesDto::with_cursor(items, next_cursor)),
)
.into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}