diff --git a/virtweb_backend/src/app_config.rs b/virtweb_backend/src/app_config.rs index e57c3e1..37f1358 100644 --- a/virtweb_backend/src/app_config.rs +++ b/virtweb_backend/src/app_config.rs @@ -250,6 +250,11 @@ impl AppConfig { self.storage_path().join("iso") } + /// Get disk images storage directory + pub fn disk_images_storage_path(&self) -> PathBuf { + self.storage_path().join("disk_images") + } + /// Get VM vnc sockets directory pub fn vnc_sockets_path(&self) -> PathBuf { self.storage_path().join("vnc") diff --git a/virtweb_backend/src/constants.rs b/virtweb_backend/src/constants.rs index 8648a63..878aa9d 100644 --- a/virtweb_backend/src/constants.rs +++ b/virtweb_backend/src/constants.rs @@ -27,6 +27,13 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ /// ISO max size pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; +/// Allowed uploaded disk images formats +pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] = + ["application/x-qemu-disk", "application/gzip"]; + +/// Disk image max size +pub const DISK_IMAGE_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000 * 1000; + /// Min VM memory size (MB) pub const MIN_VM_MEMORY: usize = 100; diff --git a/virtweb_backend/src/controllers/disk_images_controller.rs b/virtweb_backend/src/controllers/disk_images_controller.rs new file mode 100644 index 0000000..8bd1dc0 --- /dev/null +++ b/virtweb_backend/src/controllers/disk_images_controller.rs @@ -0,0 +1,57 @@ +use crate::app_config::AppConfig; +use crate::constants; +use crate::controllers::HttpResult; +use crate::utils::files_utils; +use actix_multipart::form::MultipartForm; +use actix_multipart::form::tempfile::TempFile; +use actix_web::HttpResponse; + +#[derive(Debug, MultipartForm)] +pub struct UploadDiskImageForm { + #[multipart(rename = "file")] + files: Vec, +} + +/// Upload disk image file +pub async fn upload(MultipartForm(mut form): MultipartForm) -> HttpResult { + if form.files.is_empty() { + log::error!("Missing uploaded disk file!"); + return Ok(HttpResponse::BadRequest().json("Missing file!")); + } + + let file = form.files.remove(0); + + // Check uploaded file size + if file.size > constants::DISK_IMAGE_MAX_SIZE { + return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!")); + } + + // Check file mime type + if let Some(mime_type) = file.content_type { + if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) { + return Ok(HttpResponse::BadRequest().json(format!( + "Unsupported file type for disk upload: {}", + mime_type + ))); + } + } + + // Extract and check file name + let Some(file_name) = file.file_name else { + return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!")); + }; + if !files_utils::check_file_name(&file_name) { + return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!")); + } + + // Check if a file with the same name already exists + let dest_path = AppConfig::get().disk_images_storage_path().join(file_name); + if dest_path.is_file() { + return Ok(HttpResponse::Conflict().json("A file with the same name already exists!")); + } + + // Copy the file to the destination + file.file.persist(dest_path)?; + + Ok(HttpResponse::Ok().json("Successfully uploaded disk image!")) +} diff --git a/virtweb_backend/src/controllers/mod.rs b/virtweb_backend/src/controllers/mod.rs index 6e586d5..0f8f42d 100644 --- a/virtweb_backend/src/controllers/mod.rs +++ b/virtweb_backend/src/controllers/mod.rs @@ -7,6 +7,7 @@ use std::fmt::{Display, Formatter}; pub mod api_tokens_controller; pub mod auth_controller; +pub mod disk_images_controller; pub mod groups_controller; pub mod iso_controller; pub mod network_controller; diff --git a/virtweb_backend/src/controllers/server_controller.rs b/virtweb_backend/src/controllers/server_controller.rs index 1e6ec15..0bba981 100644 --- a/virtweb_backend/src/controllers/server_controller.rs +++ b/virtweb_backend/src/controllers/server_controller.rs @@ -16,6 +16,7 @@ struct StaticConfig { local_auth_enabled: bool, oidc_auth_enabled: bool, iso_mimetypes: &'static [&'static str], + disk_images_mimetypes: &'static [&'static str], net_mac_prefix: &'static str, builtin_nwfilter_rules: &'static [&'static str], nwfilter_chains: &'static [&'static str], @@ -37,6 +38,7 @@ struct SLenConstraints { #[derive(serde::Serialize)] struct ServerConstraints { iso_max_size: usize, + disk_image_max_size: usize, vnc_token_duration: u64, vm_name_size: LenConstraints, vm_title_size: LenConstraints, @@ -63,11 +65,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { local_auth_enabled: *local_auth, oidc_auth_enabled: !AppConfig::get().disable_oidc, iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, + disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES, net_mac_prefix: constants::NET_MAC_ADDR_PREFIX, builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, nwfilter_chains: &constants::NETWORK_CHAINS, constraints: ServerConstraints { iso_max_size: constants::ISO_MAX_SIZE, + disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE, vnc_token_duration: VNC_TOKEN_LIFETIME, diff --git a/virtweb_backend/src/main.rs b/virtweb_backend/src/main.rs index 6878bdd..7d38406 100644 --- a/virtweb_backend/src/main.rs +++ b/virtweb_backend/src/main.rs @@ -1,3 +1,4 @@ +use std::cmp::max; use actix::Actor; use actix_cors::Cors; use actix_identity::IdentityMiddleware; @@ -22,8 +23,9 @@ use virtweb_backend::constants::{ MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, }; use virtweb_backend::controllers::{ - api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller, - nwfilter_controller, server_controller, static_controller, vm_controller, + api_tokens_controller, auth_controller, disk_images_controller, groups_controller, + iso_controller, network_controller, nwfilter_controller, server_controller, static_controller, + vm_controller, }; use virtweb_backend::libvirt_client::LibVirtClient; use virtweb_backend::middlewares::auth_middleware::AuthChecker; @@ -55,6 +57,7 @@ async fn main() -> std::io::Result<()> { log::debug!("Create required directory, if missing"); files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); + files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); @@ -118,7 +121,7 @@ async fn main() -> std::io::Result<()> { })) .app_data(conn.clone()) // Uploaded files - .app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE)) + .app_data(MultipartFormConfig::default().total_limit(max(constants::DISK_IMAGE_MAX_SIZE,constants::ISO_MAX_SIZE))) .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) // Server controller .route( @@ -329,6 +332,11 @@ async fn main() -> std::io::Result<()> { "/api/nwfilter/{uid}", web::delete().to(nwfilter_controller::delete), ) + // Disk images library + .route( + "/api/disk_images/upload", + web::post().to(disk_images_controller::upload), + ) // API tokens controller .route( "/api/token/create", diff --git a/virtweb_frontend/src/App.tsx b/virtweb_frontend/src/App.tsx index 96bea21..1356de7 100644 --- a/virtweb_frontend/src/App.tsx +++ b/virtweb_frontend/src/App.tsx @@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute"; import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { BaseLoginPage } from "./widgets/BaseLoginPage"; +import { DiskImagesRoute } from "./routes/DiskImagesRoute"; interface AuthContext { signedIn: boolean; @@ -63,6 +64,8 @@ export function App() { }> } /> + } /> + } /> } /> diff --git a/virtweb_frontend/src/api/DiskImageApi.ts b/virtweb_frontend/src/api/DiskImageApi.ts new file mode 100644 index 0000000..9b36a51 --- /dev/null +++ b/virtweb_frontend/src/api/DiskImageApi.ts @@ -0,0 +1,31 @@ +import { APIClient } from "./ApiClient"; + +export interface DiskImage {} + +export class DiskImageApi { + /** + * Upload a new disk image 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: "/disk_images/upload", + formData: fd, + upProgress: progress, + }); + } + + /** + * Get the list of disk images + */ + static async GetList(): Promise { + // TODO + return []; + } +} diff --git a/virtweb_frontend/src/api/ServerApi.ts b/virtweb_frontend/src/api/ServerApi.ts index 597bfc6..050fe64 100644 --- a/virtweb_frontend/src/api/ServerApi.ts +++ b/virtweb_frontend/src/api/ServerApi.ts @@ -5,6 +5,7 @@ export interface ServerConfig { local_auth_enabled: boolean; oidc_auth_enabled: boolean; iso_mimetypes: string[]; + disk_images_mimetypes: string[]; net_mac_prefix: string; builtin_nwfilter_rules: string[]; nwfilter_chains: string[]; @@ -13,6 +14,7 @@ export interface ServerConfig { export interface ServerConstraints { iso_max_size: number; + disk_image_max_size: number; vnc_token_duration: number; vm_name_size: LenConstraint; vm_title_size: LenConstraint; diff --git a/virtweb_frontend/src/routes/DiskImagesRoute.tsx b/virtweb_frontend/src/routes/DiskImagesRoute.tsx new file mode 100644 index 0000000..10cd9ec --- /dev/null +++ b/virtweb_frontend/src/routes/DiskImagesRoute.tsx @@ -0,0 +1,151 @@ +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + Button, + IconButton, + LinearProgress, + Tooltip, + Typography, +} from "@mui/material"; +import { filesize } from "filesize"; +import React from "react"; +import { DiskImage, DiskImageApi } from "../api/DiskImageApi"; +import { ServerApi } from "../api/ServerApi"; +import { useAlert } from "../hooks/providers/AlertDialogProvider"; +import { useSnackbar } from "../hooks/providers/SnackbarProvider"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { FileInput } from "../widgets/forms/FileInput"; +import { VirtWebPaper } from "../widgets/VirtWebPaper"; +import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; + +export function DiskImagesRoute(): React.ReactElement { + const [list, setList] = React.useState(); + + const loadKey = React.useRef(1); + + const load = async () => { + setList(await DiskImageApi.GetList()); + }; + + const reload = () => { + loadKey.current += 1; + setList(undefined); + }; + + return ( + + ( + + + + + + + + } + > + + + + )} + /> + + ); +} + +function UploadDiskImageCard(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.constraints.disk_image_max_size + ) { + alert( + `The file is too big (max size allowed: ${filesize( + ServerApi.Config.constraints.disk_image_max_size + )}` + ); + return; + } + + if ( + newValue && + !ServerApi.Config.disk_images_mimetypes.includes(newValue.type) + ) { + alert(`Selected file mimetype is not allowed! (${newValue.type})`); + return; + } + + setValue(newValue); + }; + + const upload = async () => { + try { + setUploadProgress(0); + await DiskImageApi.Upload(value!, setUploadProgress); + + setValue(null); + snackbar("The file was successfully uploaded!"); + + p.onFileUploaded(); + } 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 && } +
+
+ ); +} + +function DiskImageList(p: { + list: DiskImage[]; + onReload: () => void; +}): React.ReactElement { + return <>todo; +} diff --git a/virtweb_frontend/src/routes/NotFound.tsx b/virtweb_frontend/src/routes/NotFound.tsx index 9d6af83..78c2790 100644 --- a/virtweb_frontend/src/routes/NotFound.tsx +++ b/virtweb_frontend/src/routes/NotFound.tsx @@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink"; export function NotFoundRoute(): React.ReactElement { return ( -
+

404 Not found

The page you requested was not found!

diff --git a/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx b/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx index 6f5f522..97fcb9e 100644 --- a/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx +++ b/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx @@ -2,6 +2,7 @@ import { mdiApi, mdiBoxShadow, mdiDisc, + mdiHarddisk, mdiHome, mdiInformation, mdiLan, @@ -66,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement { uri="/nwfilter" icon={} /> + } + />    - {p.value ? p.value.name : "Insert a file"} + {p.value ? p.value.name : "Select a file"} ),