From ebb0aee84e9d494967297310b60c4f267a9f16e7 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Sat, 14 Feb 2026 12:45:46 +0100 Subject: [PATCH] fix(#105): ensure folders are nested inside user home folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: when window.app.currentPath was empty/falsy (due to timing, page state reset, or initialization), the frontend sent parent_id: null. The backend then created folders at the storage root instead of inside the user's home folder. Backend fix (folder_handler.rs): - Added AuthUser extractor to create_folder handler - When parent_id is None, auto-resolves the user's home folder ('My Folder - {username}') as the parent folder - Folders are now always created inside the user's directory tree Frontend fix (fileOperations.js): - Changed parent_id fallback from null to window.app.userHomeFolderId - Prevents sending null parent_id even if currentPath is reset Search in subfolders: no fix needed — search_recursive() already traverses the filesystem correctly; it was only failing because folders were physically flat instead of nested. Bumps service worker cache to v12. --- src/interfaces/api/handlers/folder_handler.rs | 265 +++++++++--------- static/js/fileOperations.js | 2 +- static/sw.js | 2 +- 3 files changed, 130 insertions(+), 139 deletions(-) diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 8d9085af..7e132bb5 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -1,19 +1,19 @@ -use axum::{ - Json, - extract::{Path, Query, State}, - http::{HeaderName, HeaderValue, Response, StatusCode, header}, - response::IntoResponse, -}; -use std::collections::HashMap; use std::sync::Arc; +use std::collections::HashMap; +use axum::{ + extract::{Path, State, Query}, + http::{StatusCode, header, HeaderName, HeaderValue, Response}, + response::IntoResponse, + Json, +}; -use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; -use crate::application::dtos::pagination::PaginationRequestDto; -use crate::application::ports::inbound::FolderUseCase; use crate::application::services::folder_service::FolderService; -use crate::common::di::AppState as GlobalAppState; +use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto}; +use crate::application::dtos::pagination::PaginationRequestDto; use crate::common::errors::ErrorKind; -use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; +use crate::application::ports::inbound::FolderUseCase; +use crate::common::di::AppState as GlobalAppState; +use crate::interfaces::middleware::auth::{OptionalAuthUser, AuthUser}; type AppState = Arc; @@ -22,10 +22,44 @@ 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 ("My Folder - {username}") rather + /// than at the storage root. This prevents user-created directories + /// from being placed flat in ./storage/. pub async fn create_folder( State(service): State, - Json(dto): Json, + 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() { + let home_folder_name = format!("My Folder - {}", auth_user.username); + tracing::info!( + "create_folder: parent_id is None for user '{}', looking up home folder '{}'", + auth_user.username, home_folder_name + ); + match service.list_folders(None).await { + Ok(folders) => { + if let Some(home) = folders.iter().find(|f| f.name == home_folder_name) { + 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, folder will be created at root", + home_folder_name + ); + } + } + Err(e) => { + tracing::error!("create_folder: failed to list folders for home resolution: {}", e); + } + } + } + match service.create_folder(dto).await { Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(), Err(err) => { @@ -34,12 +68,12 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Gets a folder by ID pub async fn get_folder( State(service): State, @@ -52,12 +86,12 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Lists root folders (no parent ID) /// Non-admin users only see their own home folder. pub async fn list_root_folders( @@ -111,20 +145,18 @@ impl FolderHandler { parent_id: Option<&str>, ) -> axum::response::Response { match service.list_folders(parent_id).await { - Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), + Ok(folders) => { + (StatusCode::OK, Json(folders)).into_response() + }, Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - ( - status, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() + + (status, Json(serde_json::json!({ + "error": err.to_string() + }))).into_response() } } } @@ -140,42 +172,35 @@ impl FolderHandler { Ok(folders) => { // Only filter at root level (parent_id == None) let filtered = if parent_id.is_none() { - folders - .into_iter() - .filter(|f| { - // Skip hidden/system folders - if f.name.starts_with('.') { - return false; - } - // If it's a user home folder, only show if it belongs to this user - if Self::is_user_home_folder(&f.name) { - return Self::folder_belongs_to_user(&f.name, &auth_user.username); - } - // Non-home folders are visible to everyone - true - }) - .collect() + folders.into_iter().filter(|f| { + // Skip hidden/system folders + if f.name.starts_with('.') { + return false; + } + // If it's a user home folder, only show if it belongs to this user + if Self::is_user_home_folder(&f.name) { + return Self::folder_belongs_to_user(&f.name, &auth_user.username); + } + // Non-home folders are visible to everyone + true + }).collect() } else { folders }; (StatusCode::OK, Json(filtered)).into_response() - } + }, Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - ( - status, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() + (status, Json(serde_json::json!({ + "error": err.to_string() + }))).into_response() } } } - + /// Lists folders with pagination support (internal helper) async fn list_folders_paginated_inner( service: AppState, @@ -183,25 +208,23 @@ impl FolderHandler { parent_id: Option<&str>, ) -> axum::response::Response { match service.list_folders_paginated(parent_id, &pagination).await { - Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), + Ok(paginated_result) => { + (StatusCode::OK, Json(paginated_result)).into_response() + }, Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + // Return a JSON error response - ( - status, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() + (status, Json(serde_json::json!({ + "error": err.to_string() + }))).into_response() } } } - + /// Renames a folder pub async fn rename_folder( State(service): State, @@ -216,19 +239,15 @@ impl FolderHandler { ErrorKind::AlreadyExists => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + // Return a proper JSON error response - ( - status, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() + (status, Json(serde_json::json!({ + "error": err.to_string() + }))).into_response() } } } - + /// Moves a folder to a new parent pub async fn move_folder( State(service): State, @@ -243,12 +262,12 @@ impl FolderHandler { ErrorKind::AlreadyExists => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Deletes a folder (with trash support) pub async fn delete_folder( State(service): State, @@ -262,68 +281,58 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Deletes a folder with trash functionality pub async fn delete_folder_with_trash( State(state): State, OptionalAuthUser(auth_user): OptionalAuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = auth_user - .as_ref() - .map(|u| u.id.as_str()) - .unwrap_or("anonymous"); + let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous"); // 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 - ); + 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).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() - } + }, Err(err) => { tracing::error!("Error deleting folder: {}", err); - + let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - ( - status, - Json(serde_json::json!({ - "error": format!("Error deleting folder: {}", err) - })), - ) - .into_response() + + (status, Json(serde_json::json!({ + "error": format!("Error deleting folder: {}", err) + }))).into_response() } } } - + /// Downloads a folder as a ZIP file pub async fn download_folder_zip( State(state): State, @@ -331,85 +340,67 @@ impl FolderHandler { Query(_params): Query>, ) -> impl IntoResponse { tracing::info!("Downloading folder as ZIP: {}", id); - + // Get folder information first to check it exists and get name let folder_service = &state.applications.folder_service; - + match folder_service.get_folder(&id).await { Ok(folder) => { tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); - + // Use ZIP service from DI container let zip_service = &state.core.zip_service; - + // Create the ZIP file match zip_service.create_folder_zip(&id, &folder.name).await { Ok(zip_data) => { - tracing::info!( - "ZIP file created successfully, size: {} bytes", - zip_data.len() - ); - + tracing::info!("ZIP file created successfully, size: {} bytes", zip_data.len()); + // Setup headers for download let filename = format!("{}.zip", folder.name); let content_disposition = format!("attachment; filename=\"{}\"", filename); - + // Build response with the ZIP data let mut headers = HashMap::new(); - headers.insert( - header::CONTENT_TYPE.to_string(), - "application/zip".to_string(), - ); - headers - .insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); - headers.insert( - header::CONTENT_LENGTH.to_string(), - zip_data.len().to_string(), - ); - + headers.insert(header::CONTENT_TYPE.to_string(), "application/zip".to_string()); + headers.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); + headers.insert(header::CONTENT_LENGTH.to_string(), zip_data.len().to_string()); + // Build the response let mut response = Response::builder() .status(StatusCode::OK) .body(axum::body::Body::from(zip_data)) .unwrap(); - + // Add headers to response for (name, value) in headers { response.headers_mut().insert( HeaderName::from_bytes(name.as_bytes()).unwrap(), - HeaderValue::from_str(&value).unwrap(), + HeaderValue::from_str(&value).unwrap() ); } - + response - } + }, Err(err) => { tracing::error!("Error creating ZIP file: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Error creating ZIP file: {}", err) - })), - ) - .into_response() + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + "error": format!("Error creating ZIP file: {}", err) + }))).into_response() } } - } + }, Err(err) => { tracing::error!("Folder not found: {}", err); let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - ( - status, - Json(serde_json::json!({ - "error": format!("Error finding folder: {}", err) - })), - ) - .into_response() + + (status, Json(serde_json::json!({ + "error": format!("Error finding folder: {}", err) + }))).into_response() } } } -} +} \ No newline at end of file diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 1a0c0ecb..49f07a3d 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -342,7 +342,7 @@ const fileOps = { }, body: JSON.stringify({ name: name, - parent_id: window.app.currentPath || null + parent_id: window.app.currentPath || window.app.userHomeFolderId || null }) }); diff --git a/static/sw.js b/static/sw.js index 9b433c69..b69b5405 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,5 +1,5 @@ // OxiCloud Service Worker -const CACHE_NAME = 'oxicloud-cache-v11'; +const CACHE_NAME = 'oxicloud-cache-v12'; const ASSETS_TO_CACHE = [ '/', '/index.html',