Can upload ISO files

This commit is contained in:
2023-09-05 13:19:25 +02:00
parent 83ccd3a4b9
commit 036595fb24
24 changed files with 671 additions and 36 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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>
) : (

View File

@ -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,
};
}
}

View 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,
});
}
}

View File

@ -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;

View 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)!;
}

View 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)!;
}

View File

@ -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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}