Managed to load server configuration from WebUI

This commit is contained in:
Pierre HUBERT 2024-04-29 22:14:16 +02:00
parent 955067ad9c
commit 46648db093
15 changed files with 2159 additions and 13 deletions

View File

@ -19,6 +19,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "actix-cors"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331"
dependencies = [
"actix-utils",
"actix-web",
"derive_more",
"futures-util",
"log",
"once_cell",
"smallvec",
]
[[package]]
name = "actix-http"
version = "3.6.0"
@ -1691,6 +1706,7 @@ checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
name = "remote_backend"
version = "0.1.0"
dependencies = [
"actix-cors",
"actix-identity",
"actix-remote-ip",
"actix-session",

View File

@ -16,6 +16,7 @@ actix-web = "4.5.1"
actix-remote-ip = "0.1.0"
actix-session = { version = "0.9.0", features = ["cookie-session"] }
actix-identity = "0.7.1"
actix-cors = "0.7.0"
lazy_static = "1.4.0"
anyhow = "1.0.82"
reqwest = { version = "0.12.4", features = ["json"] }

View File

@ -10,7 +10,7 @@ pub struct AppConfig {
pub listen_address: String,
/// Website main origin
#[clap(short, long, env, default_value = "http://localhost:3000")]
#[clap(short, long, env, default_value = "http://localhost:5173")]
pub website_origin: String,
/// Proxy IP, might end with a star "*"

View File

@ -6,6 +6,7 @@ use std::fmt::{Display, Formatter};
use std::io::ErrorKind;
pub mod auth_controller;
pub mod server_controller;
/// Custom error to ease controller writing
#[derive(Debug)]

View File

@ -0,0 +1,17 @@
use crate::app_config::AppConfig;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::AuthExtractor;
use actix_web::HttpResponse;
#[derive(serde::Serialize)]
struct ServerConfig {
authenticated: bool,
disable_auth: bool,
}
pub async fn config(auth: AuthExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(ServerConfig {
authenticated: auth.is_authenticated(),
disable_auth: AppConfig::get().unsecure_disable_login,
}))
}

View File

@ -1,3 +1,4 @@
use actix_cors::Cors;
use actix_identity::config::LogoutBehaviour;
use actix_identity::IdentityMiddleware;
use actix_remote_ip::RemoteIPConfig;
@ -9,9 +10,9 @@ use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
use light_openid::basic_state_manager::BasicStateManager;
use remote_backend::app_config::AppConfig;
use remote_backend::controllers::auth_controller;
use remote_backend::constants;
use remote_backend::controllers::{auth_controller, server_controller};
use remote_backend::middlewares::auth_middleware::AuthChecker;
use remote_backend::{constants, virtweb_client};
use std::time::Duration;
#[actix_web::main]
@ -40,15 +41,28 @@ async fn main() -> std::io::Result<()> {
.login_deadline(Some(Duration::from_secs(constants::MAX_SESSION_DURATION)))
.build();
let cors = Cors::default()
.allowed_origin(&AppConfig::get().website_origin)
.allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"])
.allowed_header("X-Auth-Token")
.allow_any_header()
.supports_credentials()
.max_age(3600);
App::new()
.wrap(Logger::default())
.wrap(AuthChecker)
.wrap(identity_middleware)
.wrap(session_mw)
.wrap(cors)
.app_data(state_manager.clone())
.app_data(Data::new(RemoteIPConfig {
proxy: AppConfig::get().proxy_ip.clone(),
}))
.route(
"/api/server/config",
web::get().to(server_controller::config),
)
.route(
"/api/auth/start_oidc",
web::get().to(auth_controller::start_oidc),

1
remote_frontend/.env Normal file
View File

@ -0,0 +1 @@
VITE_APP_BACKEND=http://localhost:8002/api

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fluentui/react-components": "^9.49.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

View File

@ -1 +1,14 @@
export function App(): React.ReactElement {}
import { ServerApi } from "./api/ServerApi";
import { AsyncWidget } from "./widgets/AsyncWidget";
export function App() {
return (
<AsyncWidget
loadKey={1}
errMsg="Failed to load server configuration!"
load={ServerApi.LoadConfig}
loadingMessage="Loading server configuration..."
build={() => <>todo</>}
/>
);
}

View File

@ -0,0 +1,174 @@
interface RequestParams {
uri: string;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
formData?: FormData;
upProgress?: (progress: number) => void;
downProgress?: (e: { progress: number; total: number }) => void;
}
interface APIResponse {
data: any;
status: number;
}
export class ApiError extends Error {
constructor(message: string, public code: number, public data: any) {
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
}
}
export class APIClient {
/**
* Get backend URL
*/
static backendURL(): string {
const URL = import.meta.env.VITE_APP_BACKEND ?? "";
if (URL.length === 0) throw new Error("Backend URL undefined!");
return URL;
}
/**
* Check out whether the backend is accessed through
* HTTPS or not
*/
static IsBackendSecure(): boolean {
return this.backendURL().startsWith("https");
}
/**
* Perform a request on the backend
*/
static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined;
let headers: any = {};
// JSON request
if (args.jsonData) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(args.jsonData);
}
// Form data request
else if (args.formData) {
body = args.formData;
}
const url = this.backendURL() + args.uri;
let data;
let status: number;
// Make the request with XMLHttpRequest
if (args.upProgress) {
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) =>
args.upProgress!(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
// JSON response
if (res.headers.get("content-type") === "application/json")
data = await res.json();
// Text / XML response
else if (
["application/xml", "text/plain"].includes(
res.headers.get("content-type") ?? ""
)
)
data = await res.text();
// Binary file, tracking download progress
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 (binary file)
else data = await res.blob();
status = res.status;
}
// Handle expired tokens
if (status === 412) {
window.location.href = "/";
}
if (!args.allowFail && (status < 200 || status > 299))
throw new ApiError("Request failed!", status, data);
return {
data: data,
status: status,
};
}
}

View File

@ -0,0 +1,30 @@
import { APIClient } from "./ApiClient";
export interface ServerConfig {
authenticated: boolean;
disable_auth: boolean;
}
let config: ServerConfig | null = null;
export class ServerApi {
/**
* Get server configuration
*/
static async LoadConfig(): Promise<void> {
config = (
await APIClient.exec({
uri: "/server/config",
method: "GET",
})
).data;
}
/**
* Get cached configuration
*/
static get Config(): ServerConfig {
if (config === null) throw new Error("Missing configuration!");
return config;
}
}

View File

@ -1,7 +1,14 @@
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
#root {
width: 100%;
height: 100%;
flex: 1;
display: flex;
}

View File

@ -2,9 +2,18 @@ import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { App } from "./App";
import {
FluentProvider,
teamsHighContrastTheme,
} from "@fluentui/react-components";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<FluentProvider
theme={teamsHighContrastTheme}
style={{ display: "flex", flex: 1 }}
>
<App />
</FluentProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,77 @@
import { Button, Spinner } from "@fluentui/react-components";
import React, { useEffect, useRef, useState } from "react";
enum State {
Loading,
Ready,
Error,
}
export function AsyncWidget(p: {
loadKey: any;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
buildLoading?: () => React.ReactElement;
buildError?: (e: string) => React.ReactElement;
errAdditionalElement?: () => React.ReactElement;
loadingMessage?: string;
}): React.ReactElement {
const [state, setState] = useState(State.Loading);
const counter = useRef<any | null>(null);
const load = async () => {
try {
setState(State.Loading);
await p.load();
setState(State.Ready);
} catch (e) {
console.error(e);
setState(State.Error);
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load();
});
if (state === State.Error)
return (
p.buildError?.(p.errMsg) ?? (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
}}
>
<div style={{ margin: "50px" }}>{p.errMsg}</div>
<Button onClick={load}>Try again</Button>
{p.errAdditionalElement && p.errAdditionalElement()}
</div>
)
);
if (state === State.Loading || p.ready === false)
return (
p.buildLoading?.() ?? (
<Spinner
labelPosition="below"
label={p.loadingMessage}
style={{ margin: "auto" }}
/>
)
);
return p.build();
}