diff --git a/virtweb_backend/.gitignore b/virtweb_backend/.gitignore index 15aedc7..9642100 100644 --- a/virtweb_backend/.gitignore +++ b/virtweb_backend/.gitignore @@ -1,2 +1,3 @@ target/ .idea +storage diff --git a/virtweb_backend/Cargo.lock b/virtweb_backend/Cargo.lock index 883a2b1..3cbff97 100644 --- a/virtweb_backend/Cargo.lock +++ b/virtweb_backend/Cargo.lock @@ -99,6 +99,44 @@ dependencies = [ "syn 2.0.29", ] +[[package]] +name = "actix-multipart" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b960e2aea75f49c8f069108063d12a48d329fc8b60b786dfc7552a9d5918d2d" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "bytes", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.29", +] + [[package]] name = "actix-remote-ip" version = "0.1.0" @@ -656,6 +694,41 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.29", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.29", +] + [[package]] name = "deranged" version = "0.3.8" @@ -996,6 +1069,12 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -1280,6 +1359,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "parse-size" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae" + [[package]] name = "paste" version = "1.0.14" @@ -1560,6 +1645,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1895,6 +1989,7 @@ version = "0.1.0" dependencies = [ "actix-cors", "actix-identity", + "actix-multipart", "actix-remote-ip", "actix-session", "actix-web", @@ -1907,6 +2002,7 @@ dependencies = [ "log", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/virtweb_backend/Cargo.toml b/virtweb_backend/Cargo.toml index 6e4e9bd..e596e98 100644 --- a/virtweb_backend/Cargo.toml +++ b/virtweb_backend/Cargo.toml @@ -19,4 +19,6 @@ actix-cors = "0.6.4" serde = { version = "1.0.175", features = ["derive"] } serde_json = "1.0.105" futures-util = "0.3.28" -anyhow = "1.0.75" \ No newline at end of file +anyhow = "1.0.75" +actix-multipart = "0.6.1" +tempfile = "3.8.0" \ No newline at end of file diff --git a/virtweb_backend/src/app_config.rs b/virtweb_backend/src/app_config.rs index f10eb02..5e37c0b 100644 --- a/virtweb_backend/src/app_config.rs +++ b/virtweb_backend/src/app_config.rs @@ -1,4 +1,5 @@ use clap::Parser; +use std::path::{Path, PathBuf}; /// VirtWeb backend API #[derive(Parser, Debug, Clone)] @@ -64,6 +65,10 @@ pub struct AppConfig { #[arg(long, env, default_value = "APP_ORIGIN/oidc_cb")] oidc_redirect_url: String, + /// Storage directory + #[arg(long, env, default_value = "storage")] + pub storage: String, + /// Directory where temporary files are stored #[arg(long, env, default_value = "/tmp")] pub temp_dir: String, @@ -131,6 +136,16 @@ impl AppConfig { self.oidc_redirect_url .replace("APP_ORIGIN", &self.website_origin) } + + /// Get root storage directory + pub fn storage_path(&self) -> PathBuf { + Path::new(&self.storage).canonicalize().unwrap() + } + + /// Get iso storage directory + pub fn iso_storage_path(&self) -> PathBuf { + self.storage_path().join("iso") + } } #[derive(Debug, Clone, serde::Serialize)] diff --git a/virtweb_backend/src/constants.rs b/virtweb_backend/src/constants.rs index cdf2400..96af19f 100644 --- a/virtweb_backend/src/constants.rs +++ b/virtweb_backend/src/constants.rs @@ -15,3 +15,9 @@ pub const ROUTES_WITHOUT_AUTH: [&str; 5] = [ "/api/auth/start_oidc", "/api/auth/finish_oidc", ]; + +/// Allowed ISO mimetypes +pub const ALLOWED_ISO_MIME_TYPES: [&str; 1] = ["application/x-cd-image"]; + +/// ISO max size +pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; diff --git a/virtweb_backend/src/controllers/iso_controller.rs b/virtweb_backend/src/controllers/iso_controller.rs new file mode 100644 index 0000000..6aed72d --- /dev/null +++ b/virtweb_backend/src/controllers/iso_controller.rs @@ -0,0 +1,60 @@ +use crate::app_config::AppConfig; +use crate::constants; +use crate::controllers::HttpResult; +use crate::utils::files_utils; +use actix_multipart::form::tempfile::TempFile; +use actix_multipart::form::MultipartForm; +use actix_web::HttpResponse; + +#[derive(Debug, MultipartForm)] +pub struct UploadIsoForm { + #[multipart(rename = "file")] + files: Vec, +} + +/// Upload iso file +pub async fn upload_file(MultipartForm(mut form): MultipartForm) -> HttpResult { + if form.files.is_empty() { + log::error!("Missing uploaded ISO file!"); + return Ok(HttpResponse::BadRequest().json("Missing file!")); + } + + let file = form.files.remove(0); + + if file.size > constants::ISO_MAX_SIZE { + log::error!("Uploaded ISO file is too large!"); + return Ok(HttpResponse::BadRequest().json("File is too large!")); + } + + if let Some(m) = &file.content_type { + if !constants::ALLOWED_ISO_MIME_TYPES.contains(&m.to_string().as_str()) { + log::error!("Uploaded ISO file has an invalid mimetype!"); + return Ok(HttpResponse::BadRequest().json("Invalid mimetype!")); + } + } + + let file_name = match &file.file_name { + None => { + log::error!("Uploaded ISO file does not have a name!"); + return Ok(HttpResponse::BadRequest().json("Missing file name!")); + } + Some(f) => f, + }; + + if !files_utils::check_file_name(file_name) { + log::error!("Bad file name for uploaded iso!"); + return Ok(HttpResponse::BadRequest().json("Bad file name!")); + } + + let dest_file = AppConfig::get().iso_storage_path().join(file_name); + log::info!("Will save ISO file {:?}", dest_file); + + if dest_file.exists() { + log::error!("Conflict with uploaded iso file name!"); + return Ok(HttpResponse::Conflict().json("The file already exists!")); + } + + file.file.persist(dest_file)?; + + Ok(HttpResponse::Accepted().finish()) +} diff --git a/virtweb_backend/src/controllers/mod.rs b/virtweb_backend/src/controllers/mod.rs index 644ad74..9b0e10a 100644 --- a/virtweb_backend/src/controllers/mod.rs +++ b/virtweb_backend/src/controllers/mod.rs @@ -5,6 +5,7 @@ use std::fmt::{Display, Formatter}; use std::io::ErrorKind; pub mod auth_controller; +pub mod iso_controller; pub mod server_controller; /// Custom error to ease controller writing @@ -58,4 +59,10 @@ impl From for HttpErr { } } +impl From for HttpErr { + fn from(value: tempfile::PersistError) -> Self { + HttpErr { err: value.into() } + } +} + pub type HttpResult = Result; diff --git a/virtweb_backend/src/controllers/server_controller.rs b/virtweb_backend/src/controllers/server_controller.rs index ea2aad8..cd13e23 100644 --- a/virtweb_backend/src/controllers/server_controller.rs +++ b/virtweb_backend/src/controllers/server_controller.rs @@ -1,4 +1,5 @@ use crate::app_config::AppConfig; +use crate::constants; use crate::extractors::local_auth_extractor::LocalAuthEnabled; use actix_web::{HttpResponse, Responder}; @@ -10,11 +11,15 @@ pub async fn root_index() -> impl Responder { struct StaticConfig { local_auth_enabled: bool, oidc_auth_enabled: bool, + iso_mimetypes: &'static [&'static str], + iso_max_size: usize, } pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { HttpResponse::Ok().json(StaticConfig { local_auth_enabled: *local_auth, oidc_auth_enabled: !AppConfig::get().disable_oidc, + iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, + iso_max_size: constants::ISO_MAX_SIZE, }) } diff --git a/virtweb_backend/src/lib.rs b/virtweb_backend/src/lib.rs index d706a9c..38afb55 100644 --- a/virtweb_backend/src/lib.rs +++ b/virtweb_backend/src/lib.rs @@ -3,3 +3,4 @@ pub mod constants; pub mod controllers; pub mod extractors; pub mod middlewares; +pub mod utils; diff --git a/virtweb_backend/src/main.rs b/virtweb_backend/src/main.rs index c5baaad..b2966c3 100644 --- a/virtweb_backend/src/main.rs +++ b/virtweb_backend/src/main.rs @@ -1,6 +1,8 @@ use actix_cors::Cors; use actix_identity::config::LogoutBehaviour; use actix_identity::IdentityMiddleware; +use actix_multipart::form::tempfile::TempFileConfig; +use actix_multipart::form::MultipartFormConfig; use actix_remote_ip::RemoteIPConfig; use actix_session::storage::CookieSessionStore; use actix_session::SessionMiddleware; @@ -12,16 +14,21 @@ use actix_web::{web, App, HttpServer}; use light_openid::basic_state_manager::BasicStateManager; use std::time::Duration; use virtweb_backend::app_config::AppConfig; +use virtweb_backend::constants; use virtweb_backend::constants::{ MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, }; -use virtweb_backend::controllers::{auth_controller, server_controller}; +use virtweb_backend::controllers::{auth_controller, iso_controller, server_controller}; use virtweb_backend::middlewares::auth_middleware::AuthChecker; +use virtweb_backend::utils::files_utils; #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + log::debug!("Create required directory, if missing"); + files_utils::create_directory_if_missing(&AppConfig::get().iso_storage_path()).unwrap(); + log::info!("Start to listen on {}", AppConfig::get().listen_address); let state_manager = Data::new(BasicStateManager::new()); @@ -62,6 +69,10 @@ async fn main() -> std::io::Result<()> { .app_data(Data::new(RemoteIPConfig { proxy: AppConfig::get().proxy_ip.clone(), })) + // Uploaded files + .app_data(web::PayloadConfig::new(constants::ISO_MAX_SIZE)) + .app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE)) + .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) // Server controller .route("/", web::get().to(server_controller::root_index)) .route( @@ -89,6 +100,11 @@ async fn main() -> std::io::Result<()> { "/api/auth/sign_out", web::get().to(auth_controller::sign_out), ) + // ISO controller + .route( + "/api/iso/upload", + web::post().to(iso_controller::upload_file), + ) }) .bind(&AppConfig::get().listen_address)? .run() diff --git a/virtweb_backend/src/utils/files_utils.rs b/virtweb_backend/src/utils/files_utils.rs new file mode 100644 index 0000000..f261973 --- /dev/null +++ b/virtweb_backend/src/utils/files_utils.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +const INVALID_CHARS: [&str; 19] = [ + "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=", + "\t", +]; + +/// Check out whether a file name is valid or not +pub fn check_file_name(name: &str) -> bool { + !name.is_empty() && !INVALID_CHARS.iter().any(|c| name.contains(c)) +} + +/// Create directory if missing +pub fn create_directory_if_missing(path: &PathBuf) -> anyhow::Result<()> { + if !path.exists() { + std::fs::create_dir_all(path)?; + } + Ok(()) +} + +#[cfg(test)] +mod test { + use crate::utils::files_utils::check_file_name; + + #[test] + fn empty_file_name() { + assert!(!check_file_name("")); + } + + #[test] + fn parent_dir_file_name() { + assert!(!check_file_name("../file.test")); + } + + #[test] + fn windows_parent_dir_file_name() { + assert!(!check_file_name("..\\test.fr")); + } + + #[test] + fn special_char_file_name() { + assert!(!check_file_name("test:test.@")); + } + + #[test] + fn valid_file_name() { + assert!(check_file_name("test.iso")); + } +} diff --git a/virtweb_backend/src/utils/mod.rs b/virtweb_backend/src/utils/mod.rs new file mode 100644 index 0000000..b68ecd0 --- /dev/null +++ b/virtweb_backend/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod files_utils; diff --git a/virtweb_frontend/package-lock.json b/virtweb_frontend/package-lock.json index f7f1ac4..933f268 100644 --- a/virtweb_frontend/package-lock.json +++ b/virtweb_frontend/package-lock.json @@ -22,6 +22,8 @@ "@types/node": "^16.18.48", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", + "filesize": "^10.0.12", + "mui-file-input": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", @@ -8472,11 +8474,11 @@ } }, "node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.12.tgz", + "integrity": "sha512-6RS9gDchbn+qWmtV2uSjo5vmKizgfCQeb5jKmqx8HyzA3MoLqqyQxN+QcjkGBJt7FjJ9qFce67Auyya5rRRbpw==", "engines": { - "node": ">= 0.4.0" + "node": ">= 10.4.0" } }, "node_modules/fill-range": { @@ -12673,6 +12675,39 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/mui-file-input": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mui-file-input/-/mui-file-input-3.0.1.tgz", + "integrity": "sha512-02MtRtyPALqcQubpMdBVhScNK9T28GBcjJwv/Wg33hjJeEQzYaCiVC5OIjMO8WFoH5gEa+lYSfZAZ4rnn5r5cw==", + "dependencies": { + "pretty-bytes": "^6.1.1" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/icons-material": "^5.0.0", + "@mui/material": "^5.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/mui-file-input/node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -14886,6 +14921,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react-dev-utils/node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/react-dev-utils/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -23995,9 +24038,9 @@ } }, "filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==" + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.12.tgz", + "integrity": "sha512-6RS9gDchbn+qWmtV2uSjo5vmKizgfCQeb5jKmqx8HyzA3MoLqqyQxN+QcjkGBJt7FjJ9qFce67Auyya5rRRbpw==" }, "fill-range": { "version": "7.0.1", @@ -27010,6 +27053,21 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "mui-file-input": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mui-file-input/-/mui-file-input-3.0.1.tgz", + "integrity": "sha512-02MtRtyPALqcQubpMdBVhScNK9T28GBcjJwv/Wg33hjJeEQzYaCiVC5OIjMO8WFoH5gEa+lYSfZAZ4rnn5r5cw==", + "requires": { + "pretty-bytes": "^6.1.1" + }, + "dependencies": { + "pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==" + } + } + }, "multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -28422,6 +28480,11 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, + "filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==" + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/virtweb_frontend/package.json b/virtweb_frontend/package.json index 0d867d1..d95528a 100644 --- a/virtweb_frontend/package.json +++ b/virtweb_frontend/package.json @@ -17,6 +17,8 @@ "@types/node": "^16.18.48", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", + "filesize": "^10.0.12", + "mui-file-input": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", diff --git a/virtweb_frontend/src/App.tsx b/virtweb_frontend/src/App.tsx index dde5c17..646e7e5 100644 --- a/virtweb_frontend/src/App.tsx +++ b/virtweb_frontend/src/App.tsx @@ -12,6 +12,7 @@ import { BaseLoginPage } from "./widgets/BaseLoginPage"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { LoginRoute } from "./routes/auth/LoginRoute"; import { AuthApi } from "./api/AuthApi"; +import { IsoFilesRoute } from "./routes/IsoFilesRoute"; interface AuthContext { signedIn: boolean; @@ -32,6 +33,7 @@ export function App() { createRoutesFromElements( signedIn ? ( }> + } /> } /> ) : ( diff --git a/virtweb_frontend/src/api/ApiClient.ts b/virtweb_frontend/src/api/ApiClient.ts index 1b60200..a909bc9 100644 --- a/virtweb_frontend/src/api/ApiClient.ts +++ b/virtweb_frontend/src/api/ApiClient.ts @@ -1,5 +1,14 @@ import { AuthApi } from "./AuthApi"; +interface RequestParams { + uri: string; + method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT"; + allowFail?: boolean; + jsonData?: any; + formData?: FormData; + progress?: (progress: number) => void; +} + interface APIResponse { data: any; status: number; @@ -32,14 +41,8 @@ export class APIClient { /** * Perform a request on the backend */ - static async exec(args: { - uri: string; - method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT"; - allowFail?: boolean; - jsonData?: any; - formData?: FormData; - }): Promise { - let body = undefined; + static async exec(args: RequestParams): Promise { + let body: string | undefined | FormData = undefined; let headers: any = {}; // JSON request @@ -53,31 +56,71 @@ export class APIClient { body = args.formData; } - const res = await fetch(this.backendURL() + args.uri, { - method: args.method, - body: body, - headers: headers, - credentials: "include", - }); + const url = this.backendURL() + args.uri; - // Process response let data; - if (res.headers.get("content-type") === "application/json") - data = await res.json(); - else data = await res.blob(); + let status: number; + + // Make the request with XMLHttpRequest + if (args.progress) { + const res: XMLHttpRequest = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener("progress", (e) => + args.progress!(e.loaded / e.total) + ); + xhr.addEventListener("load", () => resolve(xhr)); + xhr.addEventListener("error", () => + reject(new Error("File upload failed")) + ); + xhr.addEventListener("abort", () => + reject(new Error("File upload aborted")) + ); + xhr.addEventListener("timeout", () => + reject(new Error("File upload timeout")) + ); + xhr.open(args.method, url, true); + xhr.withCredentials = true; + for (const key in headers) { + if (headers.hasOwnProperty(key)) + xhr.setRequestHeader(key, headers[key]); + } + xhr.send(body); + }); + + status = res.status; + if (res.responseType === "json") data = JSON.parse(res.responseText); + else data = res.response; + } + + // Make the request with fetch + else { + const res = await fetch(url, { + method: args.method, + body: body, + headers: headers, + credentials: "include", + }); + + // Process response + if (res.headers.get("content-type") === "application/json") + data = await res.json(); + else data = await res.blob(); + + status = res.status; + } // Handle expired tokens - if (res.status === 412) { + if (status === 412) { AuthApi.UnsetAuthenticated(); window.location.href = "/"; } - if (!args.allowFail && !res.ok) - throw new ApiError("Request failed!", res.status, data); + if (!args.allowFail && (status < 200 || status > 299)) + throw new ApiError("Request failed!", status, data); return { data: data, - status: res.status, + status: status, }; } } diff --git a/virtweb_frontend/src/api/IsoFilesApi.ts b/virtweb_frontend/src/api/IsoFilesApi.ts new file mode 100644 index 0000000..3114b90 --- /dev/null +++ b/virtweb_frontend/src/api/IsoFilesApi.ts @@ -0,0 +1,21 @@ +import { APIClient } from "./ApiClient"; + +export class IsoFilesApi { + /** + * Upload a new ISO file to the server + */ + static async Upload( + file: File, + progress: (progress: number) => void + ): Promise { + const fd = new FormData(); + fd.append("file", file); + + await APIClient.exec({ + method: "POST", + uri: "/iso/upload", + formData: fd, + progress: progress, + }); + } +} diff --git a/virtweb_frontend/src/api/ServerApi.ts b/virtweb_frontend/src/api/ServerApi.ts index 9065694..06eb01e 100644 --- a/virtweb_frontend/src/api/ServerApi.ts +++ b/virtweb_frontend/src/api/ServerApi.ts @@ -3,6 +3,8 @@ import { APIClient } from "./ApiClient"; export interface ServerConfig { local_auth_enabled: boolean; oidc_auth_enabled: boolean; + iso_mimetypes: string[]; + iso_max_size: number; } let config: ServerConfig | null = null; diff --git a/virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx b/virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx new file mode 100644 index 0000000..d0d996a --- /dev/null +++ b/virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx @@ -0,0 +1,68 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import React, { PropsWithChildren } from "react"; + +type AlertContext = (message: string, title?: string) => Promise; + +const AlertContextK = React.createContext(null); + +export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement { + const [open, setOpen] = React.useState(false); + + const [title, setTitle] = React.useState(undefined); + const [message, setMessage] = React.useState(""); + + const cb = React.useRef void)>(null); + + const handleClose = () => { + setOpen(false); + + if (cb.current !== null) cb.current(); + cb.current = null; + }; + + const hook: AlertContext = (message, title) => { + setTitle(title); + setMessage(message); + setOpen(true); + + return new Promise((res) => { + cb.current = res; + }); + }; + + return ( + <> + {p.children} + + + {title && {title}} + + + {message} + + + + + + + + ); +} + +export function useAlert(): AlertContext { + return React.useContext(AlertContextK)!; +} diff --git a/virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx b/virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx new file mode 100644 index 0000000..203b3c9 --- /dev/null +++ b/virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx @@ -0,0 +1,43 @@ +import { Snackbar } from "@mui/material"; + +import React, { PropsWithChildren } from "react"; + +type SnackbarContext = (message: string, duration?: number) => void; + +const SnackbarContextK = React.createContext(null); + +export function SnackbarProvider(p: PropsWithChildren): React.ReactElement { + const [open, setOpen] = React.useState(false); + + const [message, setMessage] = React.useState(""); + const [duration, setDuration] = React.useState(0); + + const handleClose = () => { + setOpen(false); + }; + + const hook: SnackbarContext = (message, duration) => { + setMessage(message); + setDuration(duration ?? 6000); + setOpen(true); + }; + + return ( + <> + + {p.children} + + + + + ); +} + +export function useSnackbar(): SnackbarContext { + return React.useContext(SnackbarContextK)!; +} diff --git a/virtweb_frontend/src/index.tsx b/virtweb_frontend/src/index.tsx index 7493df2..85635e4 100644 --- a/virtweb_frontend/src/index.tsx +++ b/virtweb_frontend/src/index.tsx @@ -11,6 +11,8 @@ import reportWebVitals from "./reportWebVitals"; import { LoadServerConfig } from "./widgets/LoadServerConfig"; import { ThemeProvider, createTheme } from "@mui/material"; import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; +import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; +import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; const darkTheme = createTheme({ palette: { @@ -24,11 +26,15 @@ const root = ReactDOM.createRoot( root.render( - - - - - + + + + + + + + {" "} + ); diff --git a/virtweb_frontend/src/routes/IsoFilesRoute.tsx b/virtweb_frontend/src/routes/IsoFilesRoute.tsx new file mode 100644 index 0000000..3cc5e68 --- /dev/null +++ b/virtweb_frontend/src/routes/IsoFilesRoute.tsx @@ -0,0 +1,90 @@ +import { Button, LinearProgress, Typography } from "@mui/material"; +import { filesize } from "filesize"; +import { MuiFileInput } from "mui-file-input"; +import React from "react"; +import { IsoFilesApi } from "../api/IsoFilesApi"; +import { ServerApi } from "../api/ServerApi"; +import { useAlert } from "../hooks/providers/AlertDialogProvider"; +import { useSnackbar } from "../hooks/providers/SnackbarProvider"; +import { VirtWebPaper } from "../widgets/VirtWebPaper"; +import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; + +export function IsoFilesRoute(): React.ReactElement { + return ( + + alert("file uploaded!")} /> + + ); +} + +function UploadIsoFileForm(p: { + onFileUploaded: () => void; +}): React.ReactElement { + const alert = useAlert(); + const snackbar = useSnackbar(); + + const [value, setValue] = React.useState(null); + const [uploadProgress, setUploadProgress] = React.useState( + null + ); + + const handleChange = (newValue: File | null) => { + if (newValue && newValue.size > ServerApi.Config.iso_max_size) { + alert( + `The file is too big (max size allowed: ${filesize( + ServerApi.Config.iso_max_size + )}` + ); + return; + } + + if (newValue && !ServerApi.Config.iso_mimetypes.includes(newValue.type)) { + alert(`Selected file mimetype is not allowed! (${newValue.type})`); + return; + } + + setValue(newValue); + }; + + const upload = async () => { + try { + setUploadProgress(0); + await IsoFilesApi.Upload(value!, setUploadProgress); + + setValue(null); + snackbar("The file was successfully uploaded!"); + } catch (e) { + console.error(e); + await alert("Failed to perform file upload! " + e); + } + + setUploadProgress(null); + }; + + if (uploadProgress !== null) { + return ( + + + Upload in progress ({Math.floor(uploadProgress * 100)}%)... + + + + ); + } + + return ( + +
+ + + + {value && } +
+
+ ); +} diff --git a/virtweb_frontend/src/widgets/VirtWebPaper.tsx b/virtweb_frontend/src/widgets/VirtWebPaper.tsx new file mode 100644 index 0000000..5991e74 --- /dev/null +++ b/virtweb_frontend/src/widgets/VirtWebPaper.tsx @@ -0,0 +1,18 @@ +import { Paper, Typography } from "@mui/material"; +import { PropsWithChildren } from "react"; + +export function VirtWebPaper( + p: { label: string } & PropsWithChildren +): React.ReactElement { + return ( + + + {p.label} + + {p.children} + + ); +} diff --git a/virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx b/virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx new file mode 100644 index 0000000..4467516 --- /dev/null +++ b/virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx @@ -0,0 +1,18 @@ +import { Typography } from "@mui/material"; +import { PropsWithChildren } from "react"; + +export function VirtWebRouteContainer( + p: { + label: string; + } & PropsWithChildren +): React.ReactElement { + return ( +
+ + {p.label} + + + {p.children} +
+ ); +}