Can upload ISO files
This commit is contained in:
		@@ -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 ? (
 | 
			
		||||
        <Route path="*" element={<BaseAuthenticatedPage />}>
 | 
			
		||||
          <Route path="iso" element={<IsoFilesRoute />} />
 | 
			
		||||
          <Route path="*" element={<NotFoundRoute />} />
 | 
			
		||||
        </Route>
 | 
			
		||||
      ) : (
 | 
			
		||||
 
 | 
			
		||||
@@ -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<APIResponse> {
 | 
			
		||||
    let body = undefined;
 | 
			
		||||
  static async exec(args: RequestParams): Promise<APIResponse> {
 | 
			
		||||
    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,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								virtweb_frontend/src/api/IsoFilesApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								virtweb_frontend/src/api/IsoFilesApi.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<void> {
 | 
			
		||||
    const fd = new FormData();
 | 
			
		||||
    fd.append("file", file);
 | 
			
		||||
 | 
			
		||||
    await APIClient.exec({
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      uri: "/iso/upload",
 | 
			
		||||
      formData: fd,
 | 
			
		||||
      progress: progress,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										68
									
								
								virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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<void>;
 | 
			
		||||
 | 
			
		||||
const AlertContextK = React.createContext<AlertContext | null>(null);
 | 
			
		||||
 | 
			
		||||
export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
 | 
			
		||||
  const [open, setOpen] = React.useState(false);
 | 
			
		||||
 | 
			
		||||
  const [title, setTitle] = React.useState<string | undefined>(undefined);
 | 
			
		||||
  const [message, setMessage] = React.useState("");
 | 
			
		||||
 | 
			
		||||
  const cb = React.useRef<null | (() => 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 (
 | 
			
		||||
    <>
 | 
			
		||||
      <AlertContextK.Provider value={hook}>{p.children}</AlertContextK.Provider>
 | 
			
		||||
 | 
			
		||||
      <Dialog
 | 
			
		||||
        open={open}
 | 
			
		||||
        onClose={handleClose}
 | 
			
		||||
        aria-labelledby="alert-dialog-title"
 | 
			
		||||
        aria-describedby="alert-dialog-description"
 | 
			
		||||
      >
 | 
			
		||||
        {title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
 | 
			
		||||
        <DialogContent>
 | 
			
		||||
          <DialogContentText id="alert-dialog-description">
 | 
			
		||||
            {message}
 | 
			
		||||
          </DialogContentText>
 | 
			
		||||
        </DialogContent>
 | 
			
		||||
        <DialogActions>
 | 
			
		||||
          <Button onClick={handleClose} autoFocus>
 | 
			
		||||
            Ok
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DialogActions>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useAlert(): AlertContext {
 | 
			
		||||
  return React.useContext(AlertContextK)!;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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<SnackbarContext | null>(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 (
 | 
			
		||||
    <>
 | 
			
		||||
      <SnackbarContextK.Provider value={hook}>
 | 
			
		||||
        {p.children}
 | 
			
		||||
      </SnackbarContextK.Provider>
 | 
			
		||||
 | 
			
		||||
      <Snackbar
 | 
			
		||||
        open={open}
 | 
			
		||||
        autoHideDuration={duration}
 | 
			
		||||
        onClose={handleClose}
 | 
			
		||||
        message={message}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useSnackbar(): SnackbarContext {
 | 
			
		||||
  return React.useContext(SnackbarContextK)!;
 | 
			
		||||
}
 | 
			
		||||
@@ -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(
 | 
			
		||||
  <React.StrictMode>
 | 
			
		||||
    <ThemeProvider theme={darkTheme}>
 | 
			
		||||
      <LoadingMessageProvider>
 | 
			
		||||
        <LoadServerConfig>
 | 
			
		||||
          <App />
 | 
			
		||||
        </LoadServerConfig>
 | 
			
		||||
      </LoadingMessageProvider>
 | 
			
		||||
      <AlertDialogProvider>
 | 
			
		||||
        <SnackbarProvider>
 | 
			
		||||
          <LoadingMessageProvider>
 | 
			
		||||
            <LoadServerConfig>
 | 
			
		||||
              <App />
 | 
			
		||||
            </LoadServerConfig>
 | 
			
		||||
          </LoadingMessageProvider>
 | 
			
		||||
        </SnackbarProvider>{" "}
 | 
			
		||||
      </AlertDialogProvider>
 | 
			
		||||
    </ThemeProvider>
 | 
			
		||||
  </React.StrictMode>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										90
									
								
								virtweb_frontend/src/routes/IsoFilesRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								virtweb_frontend/src/routes/IsoFilesRoute.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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 (
 | 
			
		||||
    <VirtWebRouteContainer label="ISO files management">
 | 
			
		||||
      <UploadIsoFileForm onFileUploaded={() => alert("file uploaded!")} />
 | 
			
		||||
    </VirtWebRouteContainer>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function UploadIsoFileForm(p: {
 | 
			
		||||
  onFileUploaded: () => void;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const alert = useAlert();
 | 
			
		||||
  const snackbar = useSnackbar();
 | 
			
		||||
 | 
			
		||||
  const [value, setValue] = React.useState<File | null>(null);
 | 
			
		||||
  const [uploadProgress, setUploadProgress] = React.useState<number | null>(
 | 
			
		||||
    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 (
 | 
			
		||||
      <VirtWebPaper label="File upload">
 | 
			
		||||
        <Typography variant="body1">
 | 
			
		||||
          Upload in progress ({Math.floor(uploadProgress * 100)}%)...
 | 
			
		||||
        </Typography>
 | 
			
		||||
        <LinearProgress variant="determinate" value={uploadProgress * 100} />
 | 
			
		||||
      </VirtWebPaper>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <VirtWebPaper label="File upload">
 | 
			
		||||
      <div style={{ display: "flex", alignItems: "center" }}>
 | 
			
		||||
        <span style={{ width: "10px" }}></span>
 | 
			
		||||
        <MuiFileInput
 | 
			
		||||
          value={value}
 | 
			
		||||
          onChange={handleChange}
 | 
			
		||||
          style={{ flex: 1 }}
 | 
			
		||||
          inputProps={{ accept: ServerApi.Config.iso_mimetypes.join(",") }}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {value && <Button onClick={upload}>Upload file</Button>}
 | 
			
		||||
      </div>
 | 
			
		||||
    </VirtWebPaper>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								virtweb_frontend/src/widgets/VirtWebPaper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								virtweb_frontend/src/widgets/VirtWebPaper.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { Paper, Typography } from "@mui/material";
 | 
			
		||||
import { PropsWithChildren } from "react";
 | 
			
		||||
 | 
			
		||||
export function VirtWebPaper(
 | 
			
		||||
  p: { label: string } & PropsWithChildren
 | 
			
		||||
): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <Paper elevation={2} style={{ padding: "10px" }}>
 | 
			
		||||
      <Typography
 | 
			
		||||
        variant="subtitle1"
 | 
			
		||||
        style={{ marginBottom: "10px", fontWeight: "bold" }}
 | 
			
		||||
      >
 | 
			
		||||
        {p.label}
 | 
			
		||||
      </Typography>
 | 
			
		||||
      {p.children}
 | 
			
		||||
    </Paper>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { Typography } from "@mui/material";
 | 
			
		||||
import { PropsWithChildren } from "react";
 | 
			
		||||
 | 
			
		||||
export function VirtWebRouteContainer(
 | 
			
		||||
  p: {
 | 
			
		||||
    label: string;
 | 
			
		||||
  } & PropsWithChildren
 | 
			
		||||
): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <div style={{ margin: "50px" }}>
 | 
			
		||||
      <Typography variant="h4" style={{ marginBottom: "20px" }}>
 | 
			
		||||
        {p.label}
 | 
			
		||||
      </Typography>
 | 
			
		||||
 | 
			
		||||
      {p.children}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user