diff --git a/virtweb_backend/Cargo.lock b/virtweb_backend/Cargo.lock index bf900f5..73e1995 100644 --- a/virtweb_backend/Cargo.lock +++ b/virtweb_backend/Cargo.lock @@ -34,6 +34,29 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d832782fac6ca7369a70c9ee9a20554623c5e51c76e190ad151780ebea1cf689" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "askama_escape", + "bitflags 1.3.2", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", +] + [[package]] name = "actix-http" version = "3.4.0" @@ -418,6 +441,12 @@ version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + [[package]] name = "async-trait" version = "0.1.73" @@ -1023,6 +1052,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.8.0" @@ -1241,6 +1276,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1922,6 +1967,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" @@ -1999,6 +2053,7 @@ name = "virtweb_backend" version = "0.1.0" dependencies = [ "actix-cors", + "actix-files", "actix-identity", "actix-multipart", "actix-remote-ip", diff --git a/virtweb_backend/Cargo.toml b/virtweb_backend/Cargo.toml index 192fc47..8078cc6 100644 --- a/virtweb_backend/Cargo.toml +++ b/virtweb_backend/Cargo.toml @@ -16,6 +16,7 @@ actix-remote-ip = "0.1.0" actix-session = { version = "0.7.2", features = ["cookie-session"] } actix-identity = "0.5.2" actix-cors = "0.6.4" +actix-files = "0.6.2" serde = { version = "1.0.175", features = ["derive"] } serde_json = "1.0.105" futures-util = "0.3.28" diff --git a/virtweb_backend/src/controllers/iso_controller.rs b/virtweb_backend/src/controllers/iso_controller.rs index f206e8a..3ccf24c 100644 --- a/virtweb_backend/src/controllers/iso_controller.rs +++ b/virtweb_backend/src/controllers/iso_controller.rs @@ -2,9 +2,10 @@ use crate::app_config::AppConfig; use crate::constants; use crate::controllers::HttpResult; use crate::utils::files_utils; +use actix_files::NamedFile; use actix_multipart::form::tempfile::TempFile; use actix_multipart::form::MultipartForm; -use actix_web::{web, HttpResponse}; +use actix_web::{web, HttpRequest, HttpResponse}; use futures_util::StreamExt; use std::fs::File; use std::io::Write; @@ -128,12 +129,27 @@ pub async fn get_list() -> HttpResult { } #[derive(serde::Deserialize)] -pub struct DeleteFilePath { +pub struct DownloadFilePath { filename: String, } +/// Download ISO file +pub async fn download_file(p: web::Path, req: HttpRequest) -> HttpResult { + if !files_utils::check_file_name(&p.filename) { + return Ok(HttpResponse::BadRequest().json("Invalid file name!")); + } + + let file_path = AppConfig::get().iso_storage_path().join(&p.filename); + + if !file_path.exists() { + return Ok(HttpResponse::NotFound().json("File does not exists!")); + } + + Ok(NamedFile::open(file_path)?.into_response(&req)) +} + /// Delete ISO file -pub async fn delete_file(p: web::Path) -> HttpResult { +pub async fn delete_file(p: web::Path) -> HttpResult { if !files_utils::check_file_name(&p.filename) { return Ok(HttpResponse::BadRequest().json("Invalid file name!")); } diff --git a/virtweb_backend/src/main.rs b/virtweb_backend/src/main.rs index 2a42ca2..b11be7a 100644 --- a/virtweb_backend/src/main.rs +++ b/virtweb_backend/src/main.rs @@ -109,6 +109,10 @@ async fn main() -> std::io::Result<()> { web::post().to(iso_controller::upload_from_url), ) .route("/api/iso/list", web::get().to(iso_controller::get_list)) + .route( + "/api/iso/{filename}", + web::get().to(iso_controller::download_file), + ) .route( "/api/iso/{filename}", web::delete().to(iso_controller::delete_file), diff --git a/virtweb_frontend/src/api/ApiClient.ts b/virtweb_frontend/src/api/ApiClient.ts index a909bc9..613040e 100644 --- a/virtweb_frontend/src/api/ApiClient.ts +++ b/virtweb_frontend/src/api/ApiClient.ts @@ -6,7 +6,8 @@ interface RequestParams { allowFail?: boolean; jsonData?: any; formData?: FormData; - progress?: (progress: number) => void; + upProgress?: (progress: number) => void; + downProgress?: (e: { progress: number; total: number }) => void; } interface APIResponse { @@ -62,11 +63,11 @@ export class APIClient { let status: number; // Make the request with XMLHttpRequest - if (args.progress) { + if (args.upProgress) { const res: XMLHttpRequest = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener("progress", (e) => - args.progress!(e.loaded / e.total) + args.upProgress!(e.loaded / e.total) ); xhr.addEventListener("load", () => resolve(xhr)); xhr.addEventListener("error", () => @@ -104,6 +105,48 @@ export class APIClient { // Process response if (res.headers.get("content-type") === "application/json") data = await res.json(); + // Binary file + else if (res.body !== null && args.downProgress) { + // Track download progress + const contentEncoding = res.headers.get("content-encoding"); + const contentLength = contentEncoding + ? null + : res.headers.get("content-length"); + + const total = parseInt(contentLength ?? "0", 10); + let loaded = 0; + + const resInt = new Response( + new ReadableStream({ + start(controller) { + const reader = res.body!.getReader(); + + const read = async () => { + try { + const ret = await reader.read(); + if (ret.done) { + controller.close(); + return; + } + loaded += ret.value.byteLength; + args.downProgress!({ progress: loaded, total }); + controller.enqueue(ret.value); + read(); + } catch (e) { + console.error(e); + controller.error(e); + } + }; + + read(); + }, + }) + ); + + data = await resInt.blob(); + } + + // Do not track progress else data = await res.blob(); status = res.status; diff --git a/virtweb_frontend/src/api/IsoFilesApi.ts b/virtweb_frontend/src/api/IsoFilesApi.ts index 05dea7b..4bad0a9 100644 --- a/virtweb_frontend/src/api/IsoFilesApi.ts +++ b/virtweb_frontend/src/api/IsoFilesApi.ts @@ -20,7 +20,7 @@ export class IsoFilesApi { method: "POST", uri: "/iso/upload", formData: fd, - progress: progress, + upProgress: progress, }); } @@ -47,6 +47,24 @@ export class IsoFilesApi { ).data; } + /** + * Download an ISO file + */ + static async Download( + file: IsoFile, + progress: (p: number) => void + ): Promise { + return ( + await APIClient.exec({ + method: "GET", + uri: `/iso/${file.filename}`, + downProgress(e) { + progress(Math.floor(100 * (e.progress / e.total))); + }, + }) + ).data; + } + /** * Delete iso file */ diff --git a/virtweb_frontend/src/routes/IsoFilesRoute.tsx b/virtweb_frontend/src/routes/IsoFilesRoute.tsx index 824d499..e3eccd9 100644 --- a/virtweb_frontend/src/routes/IsoFilesRoute.tsx +++ b/virtweb_frontend/src/routes/IsoFilesRoute.tsx @@ -1,11 +1,15 @@ import DeleteIcon from "@mui/icons-material/Delete"; import { + Alert, Button, + CircularProgress, IconButton, LinearProgress, TextField, + Tooltip, Typography, } from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { filesize } from "filesize"; import { MuiFileInput } from "mui-file-input"; @@ -19,6 +23,7 @@ import { AsyncWidget } from "../widgets/AsyncWidget"; import { VirtWebPaper } from "../widgets/VirtWebPaper"; import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; +import { downloadBlob } from "../utils/FilesUtils"; export function IsoFilesRoute(): React.ReactElement { const [list, setList] = React.useState(); @@ -185,6 +190,23 @@ function IsoFilesList(p: { const loadingMessage = useLoadingMessage(); const snackbar = useSnackbar(); + const [dlProgress, setDlProgress] = React.useState(); + + const downloadIso = async (entry: IsoFile) => { + setDlProgress(0); + + try { + const blob = await IsoFilesApi.Download(entry, setDlProgress); + + await downloadBlob(blob, entry.filename); + } catch (e) { + console.error(e); + alert("Failed to download iso file!"); + } + + setDlProgress(undefined); + }; + const deleteIso = async (entry: IsoFile) => { if ( !(await confirm( @@ -227,13 +249,20 @@ function IsoFilesList(p: { { field: "actions", headerName: "", - width: 70, + width: 120, renderCell(params) { return ( <> - deleteIso(params.row)}> - - + + downloadIso(params.row)}> + + + + + deleteIso(params.row)}> + + + ); }, @@ -241,13 +270,40 @@ function IsoFilesList(p: { ]; return ( - - c.filename} - rows={p.list} - columns={columns} - autoHeight={true} - /> - + <> + + {/* Download notification */} + {dlProgress !== undefined && ( + +
+ + Downloading... {dlProgress}% + + +
+
+ )} + + {/* Files list table */} + c.filename} + rows={p.list} + columns={columns} + autoHeight={true} + /> +
+ ); } diff --git a/virtweb_frontend/src/utils/FilesUtils.ts b/virtweb_frontend/src/utils/FilesUtils.ts new file mode 100644 index 0000000..80e9874 --- /dev/null +++ b/virtweb_frontend/src/utils/FilesUtils.ts @@ -0,0 +1,10 @@ +export async function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.target = "_blank"; + link.rel = "noopener"; + link.download = filename; + link.click(); +}