diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7d48a4957c..95bba7a93a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1418,6 +1418,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce4ef31cda248bbdb6e6820603b82dfcd9e833db65a43e997a0ccec777d11fe" + [[package]] name = "httparse" version = "1.8.0" @@ -1794,6 +1800,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3119,9 +3135,15 @@ dependencies = [ "bitflags 2.4.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", @@ -3253,6 +3275,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.14" diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index def882c4a2..cb99449cfd 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -29,7 +29,7 @@ macaddr = "1.0" async-trait = "0.1.75" axum = { version = "0.7.4", features = ["ws"] } serde_json = "1.0.113" -tower-http = { version = "0.5.1", features = ["compression-br", "trace"] } +tower-http = { version = "0.5.1", features = ["compression-br", "fs", "trace"] } tracing-subscriber = "0.3.18" tracing-journald = "0.3.0" tracing = "0.1.40" diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index 53f1e217c1..46c14797d0 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -1,4 +1,7 @@ -use std::process::{ExitCode, Termination}; +use std::{ + path::{Path, PathBuf}, + process::{ExitCode, Termination}, +}; use agama_lib::connection_to; use agama_server::{ @@ -10,6 +13,8 @@ use tokio::sync::broadcast::channel; use tracing_subscriber::prelude::*; use utoipa::OpenApi; +const DEFAULT_WEB_UI_DIR: &'static str = "/usr/share/agama/web_ui"; + #[derive(Subcommand, Debug)] enum Commands { /// Start the API server. @@ -27,6 +32,9 @@ pub struct ServeArgs { // Agama D-Bus address #[arg(long, default_value = "unix:path=/run/agama/bus")] dbus_address: String, + // Directory containing the web UI code. + #[arg(long)] + web_ui_dir: Option, } #[derive(Parser, Debug)] @@ -39,6 +47,17 @@ struct Cli { pub command: Commands, } +fn find_web_ui_dir() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + let path = Path::new(&home).join(".local/share/agama"); + if path.exists() { + return path; + } + } + + Path::new(DEFAULT_WEB_UI_DIR).into() +} + /// Start serving the API. /// /// `args`: command-line arguments. @@ -55,7 +74,8 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { let config = web::ServiceConfig::load()?; let dbus = connection_to(&args.dbus_address).await?; - let service = web::service(config, tx, dbus).await?; + let web_ui_dir = args.web_ui_dir.unwrap_or(find_web_ui_dir()); + let service = web::service(config, tx, dbus, web_ui_dir).await?; axum::serve(listener, service) .await .expect("could not mount app on listener"); diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index b088528589..8a26f1793c 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -28,18 +28,25 @@ pub use config::ServiceConfig; pub use docs::ApiDoc; pub use event::{Event, EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; +use std::path::Path; use tokio_stream::StreamExt; /// Returns a service that implements the web-based Agama API. /// /// * `config`: service configuration. -/// * `events`: D-Bus connection. -pub async fn service( +/// * `events`: channel to send the events through the WebSocket. +/// * `dbus`: D-Bus connection. +/// * `web_ui_dir`: public directory containing the web UI. +pub async fn service

( config: ServiceConfig, events: EventsSender, dbus: zbus::Connection, -) -> Result { - let router = MainServiceBuilder::new(events.clone()) + web_ui_dir: P, +) -> Result +where + P: AsRef, +{ + let router = MainServiceBuilder::new(events.clone(), web_ui_dir) .add_service("/l10n", l10n_service(events.clone())) .add_service("/software", software_service(dbus).await?) .with_config(config) diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index d2590bc688..3af07c6fe9 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -6,25 +6,46 @@ use axum::{ routing::{get, post}, Router, }; -use std::convert::Infallible; +use std::{ + convert::Infallible, + path::{Path, PathBuf}, +}; use tower::Service; -use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer}; +/// Builder for Agama main service. +/// +/// It is responsible for building an axum service which includes: +/// +/// * A static assets directory (`public_dir`). +/// * A websocket at the `/ws` path. +/// * An authentication endpoint at `/authenticate`. +/// * A 'ping' endpoint at '/ping'. +/// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, events: EventsSender, - router: Router, + api_router: Router, + public_dir: PathBuf, } impl MainServiceBuilder { - pub fn new(events: EventsSender) -> Self { - let router = Router::new().route("/ws", get(super::ws::ws_handler)); + /// Returns a new service builder. + /// + /// * `events`: channel to send events through the WebSocket. + /// * `public_dir`: path to the public directory. + pub fn new

(events: EventsSender, public_dir: P) -> Self + where + P: AsRef, + { + let api_router = Router::new().route("/ws", get(super::ws::ws_handler)); let config = ServiceConfig::default(); Self { events, - router, + api_router, config, + public_dir: PathBuf::from(public_dir.as_ref()), } } @@ -32,6 +53,10 @@ impl MainServiceBuilder { Self { config, ..self } } + /// Add an authenticated service. + /// + /// * `path`: Path to mount the service under `/api`. + /// * `service`: Service to mount on the given `path`. pub fn add_service(self, path: &str, service: T) -> Self where T: Service + Clone + Send + 'static, @@ -39,7 +64,7 @@ impl MainServiceBuilder { T::Future: Send + 'static, { Self { - router: self.router.nest_service(path, service), + api_router: self.api_router.nest_service(path, service), ..self } } @@ -49,12 +74,19 @@ impl MainServiceBuilder { config: self.config, events: self.events, }; - self.router + + let api_router = self + .api_router .route_layer(middleware::from_extractor_with_state::( state.clone(), )) .route("/ping", get(super::http::ping)) - .route("/authenticate", post(super::http::authenticate)) + .route("/authenticate", post(super::http::authenticate)); + + let serve = ServeDir::new(self.public_dir); + Router::new() + .nest_service("/", serve) + .nest("/api", api_router) .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new().br(true)) .with_state(state) diff --git a/rust/agama-server/tests/service.rs b/rust/agama-server/tests/service.rs index daa453af02..c4e48cdf55 100644 --- a/rust/agama-server/tests/service.rs +++ b/rust/agama-server/tests/service.rs @@ -12,22 +12,34 @@ use axum::{ Router, }; use common::{body_to_string, DBusServer}; -use std::error::Error; +use std::{error::Error, path::PathBuf}; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; async fn build_service() -> Router { let (tx, _) = channel(16); let server = DBusServer::new().start().await.unwrap(); - service(ServiceConfig::default(), tx, server.connection()) - .await - .unwrap() + service( + ServiceConfig::default(), + tx, + server.connection(), + public_dir(), + ) + .await + .unwrap() +} + +fn public_dir() -> PathBuf { + std::env::current_dir().unwrap().join("public") } #[test] async fn test_ping() -> Result<(), Box> { let web_service = build_service().await; - let request = Request::builder().uri("/ping").body(Body::empty()).unwrap(); + let request = Request::builder() + .uri("/api/ping") + .body(Body::empty()) + .unwrap(); let response = web_service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); @@ -46,13 +58,13 @@ async fn access_protected_route(token: &str, jwt_secret: &str) -> Response { jwt_secret: jwt_secret.to_string(), }; let (tx, _) = channel(16); - let web_service = MainServiceBuilder::new(tx) + let web_service = MainServiceBuilder::new(tx, public_dir()) .add_service("/protected", get(protected)) .with_config(config) .build(); let request = Request::builder() - .uri("/protected") + .uri("/api/protected") .method(Method::GET) .header("Authorization", format!("Bearer {}", token)) .body(Body::empty()) diff --git a/web/README.md b/web/README.md index 200ff95e26..e903923adc 100644 --- a/web/README.md +++ b/web/README.md @@ -1,25 +1,17 @@ -# Agama Web-Based UI +# Agama Web UI -This Cockpit modules offers a UI to the [Agama service](file:../service). The code is based on -[Cockpit's Starter Kit -(b2379f7)](https://github.com/cockpit-project/starter-kit/tree/b2379f78e203aab0028d8548b39f5f0bd2b27d2a). +The Agama web user interface is a React-based application that offers a user +interface to the [Agama service](file:../service). ## Development -TODO: update when new way is clear how to do -There are basically two ways how to develop the Agama fronted. You can -override the original Cockpit plugins with your own code in your `$HOME` directory -or you can run a development server which works as a proxy and sends the Cockpit -requests to a real Cockpit server. - -The advantage of using the development server is that you can use the -[Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/) -feature for automatically updating the code and stylesheet in the browser -without reloading the page. +The easiest way to work on the Agama Web UI is to use the development server. +The advantage is that you can use the [Hot Module Replacement] (https:// +webpack.js.org/concepts/hot-module-replacement/) feature for automatically +updating the code and stylesheet in the browser without reloading the page. ### Using a development server -TODO: update when new way is clear how to do To start the [webpack-dev-server](https://github.com/webpack/webpack-dev-server) use this command: @@ -29,28 +21,25 @@ use this command: The extra `--open` option automatically opens the server page in your default web browser. In this case the server will use the `https://localhost:8080` URL -and expects a running Cockpit instance at `https://localhost:9090`. - -At the first start the development server generates a self-signed SSL -certificate, you have to accept it in the browser. The certificate is saved to -disk and is used in the next runs so you do not have to accept it again. +and expects a running `agama-web-server` at `https://localhost:9090`. This can work also remotely, with a Agama instance running in a different machine (a virtual machine as well). In that case run ``` - COCKPIT_TARGET= npm run server -- --open + AGAMA_SERVER= npm run server -- --open ``` -Where `COCKPIT_TARGET` is the IP address or hostname of the running Agama -instance. This is especially useful if you use the Live ISO which does not contain -any development tools, you can develop the web frontend easily from your workstation. +Where `AGAMA_SERVER` is the IP address, the hostname or the full URL of the +running Agama server instance. This is especially useful if you use the Live ISO +which does not contain any development tools, you can develop the web frontend +easily from your workstation. ### Special Environment Variables -`COCKPIT_TARGET` - When running the development server set up a proxy to the -specified Cockpit server. See the [using a development -server](#using-a-development-server) section above. +`AGAMA_SERVER` - When running the development server set up a proxy to +the specified Agama web server. See the [using a development server] +(#using-a-development-server) section above. `LOCAL_CONNECTION` - Force behaving as in a local connection, useful for development or testing some Agama features. For example the keyboard layout @@ -89,7 +78,6 @@ you want a JavaScript file to be type-checked, please add a `// @ts-check` comme ### Links -- [Cockpit developer documentation](https://cockpit-project.org/guide/latest/development) - [Webpack documentation](https://webpack.js.org/configuration/) - [PatternFly documentation](https://www.patternfly.org) - [Material Symbols (aka icons)](https://fonts.google.com/icons) diff --git a/web/webpack.config.js b/web/webpack.config.js index cf50d696a0..7edbd96bff 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -24,10 +24,11 @@ const eslint = process.env.ESLINT !== '0'; /* Default to disable csslint for faster production builds */ const stylelint = process.env.STYLELINT ? (process.env.STYLELINT !== '0') : development; -// Cockpit target managed by the development server, -// by default connect to a locally running Cockpit -let cockpitTarget = process.env.COCKPIT_TARGET || "localhost:9090"; -cockpitTarget = "https://" + cockpitTarget; +// Agama API server. By default it connects to a local development server. +let agamaServer= process.env.AGAMA_SERVER || "localhost:3000"; +if (!agamaServer.startsWith("http")) { + agamaServer = "http://" + agamaServer; +} // Obtain package name from package.json const packageJson = JSON.parse(fs.readFileSync('package.json')); @@ -95,17 +96,20 @@ module.exports = { // TODO: modify it to not depend on cockpit // forward the manifests.js request and patch the response with the // current Agama manifest from the ./src/manifest.json file - "/manifests.js": { - target: cockpitTarget + "/cockpit/@localhost/", - // ignore SSL problems (self-signed certificate) + // "/manifests.js": { + // target: cockpitTarget + "/cockpit/@localhost/", + // // ignore SSL problems (self-signed certificate) + // secure: false, + // // the response is modified by the onProxyRes handler + // selfHandleResponse : true, + // onProxyRes: manifests_handler, + // }, + "/api": { + target: agamaServer, secure: false, - // the response is modified by the onProxyRes handler - selfHandleResponse : true, - onProxyRes: manifests_handler, - }, + } }, - // use https so Cockpit uses wss:// when connecting to the backend - server: "https", + server: "http", // hot replacement does not support wss:// transport when running over https://, // as a workaround use sockjs (which uses standard https:// protocol) webSocketServer: "sockjs",