diff --git a/src/interfaces/api/deserializer.rs b/src/interfaces/api/deserializer.rs new file mode 100644 index 00000000..1e5931af --- /dev/null +++ b/src/interfaces/api/deserializer.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Deserializer}; + +// deserialize comma separated string into into Vec +pub fn deserialize_csv<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer).unwrap_or_default(); + Ok(s.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect()) +} diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 8edf7e81..0249a3e8 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Json, State}, + extract::{Json, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -12,6 +12,7 @@ use crate::application::dtos::folder_dto::FolderDto; use crate::application::services::batch_operations::{ BatchOperationService, BatchResult, BatchStats, }; +use crate::interfaces::api::deserializer; use crate::interfaces::api::handlers::ApiResult; use crate::interfaces::middleware::auth::AuthUser; @@ -646,6 +647,24 @@ pub struct BatchDownloadRequest { pub folder_ids: Vec, } +#[derive(Debug, Deserialize)] +pub struct BatchDownloadQuery { + #[serde(default, deserialize_with = "deserializer::deserialize_csv")] + pub file_ids: Vec, // will deserialize query string "1,2,3" into Vec + #[serde(default, deserialize_with = "deserializer::deserialize_csv")] + pub folder_ids: Vec, // will deserialize query string "1,2,3" into Vec +} + +// convert BatchDownloadQuery into BatchDownloadRequest +impl From for BatchDownloadRequest { + fn from(q: BatchDownloadQuery) -> Self { + Self { + file_ids: q.file_ids, + folder_ids: q.folder_ids, + } + } +} + /// Handler for moving multiple files and folders to trash in batch #[utoipa::path( post, @@ -832,6 +851,29 @@ pub async fn move_folders_batch( Ok((status_code, Json(response)).into_response()) } +// Hander as a workarround for drag & drop (does not support POST requests) +#[utoipa::path( + get, + path = "/api/batch/download", + params( + ("file_ids" = Option, Query, description = "Comma-separated file IDs"), + ("folder_ids" = Option, Query, description = "Comma-separated folder IDs"), + ), + responses( + (status = 200, description = "ZIP archive stream"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "ZIP creation failed") + ), + tag = "batch" +)] +pub async fn download_batch_querystring( + State(state): State, + auth_user: AuthUser, + Query(params): Query, +) -> Result { + process_download_batch(state, auth_user, params.into()).await +} /// Handler for downloading multiple files and folders as a single ZIP. /// /// The ZIP is written to a temporary file and streamed to the client, @@ -847,10 +889,18 @@ pub async fn move_folders_batch( ), tag = "batch" )] -pub async fn download_batch( +pub async fn download_batch_post( State(state): State, auth_user: AuthUser, Json(request): Json, +) -> Result { + process_download_batch(state, auth_user, request).await +} + +async fn process_download_batch( + state: BatchHandlerState, + auth_user: AuthUser, + request: BatchDownloadRequest, ) -> Result { if request.file_ids.is_empty() && request.folder_ids.is_empty() { return Err(( diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 28831f25..fd4bfbbf 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -1,4 +1,5 @@ pub mod cookie_auth; +pub mod deserializer; pub mod handlers; pub mod routes; @@ -130,7 +131,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::batch_handler::get_folders_batch, handlers::batch_handler::move_folders_batch, handlers::batch_handler::trash_batch, - handlers::batch_handler::download_batch, + handlers::batch_handler::download_batch_post, + handlers::batch_handler::download_batch_querystring, // Music/playlist handlers (free functions) handlers::music_handler::create_playlist, handlers::music_handler::list_playlists, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index a7e1e996..d729b114 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -203,7 +203,9 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Trash operations (soft delete) .route("/trash", post(batch_handler::trash_batch)) // Download as ZIP - .route("/download", post(batch_handler::download_batch)) + .route("/download", post(batch_handler::download_batch_post)) + // work arround for drag & drop (does not support POST requests) + .route("/download", get(batch_handler::download_batch_querystring)) .with_state(batch_handler_state); // Create search routes if the service is available diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 8d844a73..3f4b86b0 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -1081,6 +1081,41 @@ const ui = { lastItemDiv = div; } + let downloadUrl; + let nameEncoded; + + // tells Browser URL to call to drop selection on operating system (desktop, file manager etc) + // will generate a zipfile if multiple + if (selectedCardFromList.length === 1) { + // only 1 file + if (info.type === 'file') { + nameEncoded = info.name.replaceAll(/:/g, '-'); // issue is that DownloadURL is using : as separator; + downloadUrl = `${window.location.origin}/api/files/${info.id}`; + } else { + // directory into ZIP + nameEncoded = info.name.replaceAll(/:/g, '-').concat('.zip'); + downloadUrl = `${window.location.origin}/api/folders/${info.id}/download?format=zip`; + } + } else { + // must use ZIP container + // TODO better naming like ("selection in ${parent.name}") modulo i18n ? ... + const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-'); + nameEncoded = `oxicloud ${now}.zip`; + const folders = []; + const files = []; + filesList.querySelectorAll(`div.selected`).forEach((e) => { + const item = itemInfo(e); + if (item.type === 'file') { + files.push(item.id); + } else { + folders.push(item.id); + } + }); + downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${files.join(',')}&folder_ids=${folders.join(',')}`; + } + + e.dataTransfer?.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); + // if more than 1 item, display the badge if (selectedCardFromList.length > 1) { const badge = document.createElement('span'); diff --git a/static/sw.js b/static/sw.js index 4d78b74f..6bb430c5 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v20'; +const CACHE_NAME = 'oxicloud-cache-v21'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest