112 lines
3.6 KiB
Rust
112 lines
3.6 KiB
Rust
//! Web frontend interface — serves the browser-based UI.
|
|||
|
|
//!
|
||
|
|
//! The frontend assets (JS/HTML/CSS) are expected to be built into
|
||
|
|
//! a `dist/` directory at compile time via `include_dir!`, or served
|
||
|
|
//! from a path at runtime.
|
||
|
|
//!
|
||
|
|
//! ## Development
|
||
|
|
//!
|
||
|
|
//! During development, point `--web-dir` to the frontend dev server
|
||
|
|
//! or build directory.
|
||
|
|
|
||
|
|
use axum::http::StatusCode;
|
||
|
|
use axum::response::{Html, IntoResponse, Response};
|
||
|
|
use axum::routing::get;
|
||
|
|
use axum::Router;
|
||
|
|
use std::path::PathBuf;
|
||
|
|
use std::sync::Arc;
|
||
|
|
use tokio::fs;
|
||
|
|
use tracing::{info, warn};
|
||
|
|
|
||
|
|
/// Web server state.
|
||
|
|
pub struct WebState {
|
||
|
|
/// Directory from which to serve static files.
|
||
|
|
pub static_dir: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Build the web frontend router.
|
||
|
|
pub fn build_router(state: Arc<WebState>) -> Router {
|
||
|
|
Router::new()
|
||
|
|
.route("/", get(index_handler))
|
||
|
|
.route("/{*path}", get(static_handler))
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Serve the index.html for root requests.
|
||
|
|
async fn index_handler(
|
||
|
|
axum::extract::State(state): axum::extract::State<Arc<WebState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let index_path = state.static_dir.join("index.html");
|
||
|
|
match fs::read_to_string(&index_path).await {
|
||
|
|
Ok(html) => Ok(Html(html)),
|
||
|
|
Err(_) => {
|
||
|
|
// Return a minimal HTML page when no frontend is built
|
||
|
|
Ok(Html(
|
||
|
|
r#"<!DOCTYPE html>
|
||
|
|
<html><head><title>Zesdex Web</title>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
|
||
|
|
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
|
||
|
|
<body>
|
||
|
|
<h1>Zesdex Web</h1>
|
||
|
|
<p>Web interface is ready.</p>
|
||
|
|
<p>To connect the frontend:</p>
|
||
|
|
<ol>
|
||
|
|
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
|
||
|
|
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
|
||
|
|
</ol>
|
||
|
|
</body></html>"#.to_string(),
|
||
|
|
))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Serve static files from the configured directory.
|
||
|
|
async fn static_handler(
|
||
|
|
axum::extract::State(state): axum::extract::State<Arc<WebState>>,
|
||
|
|
path: axum::extract::Path<String>,
|
||
|
|
) -> Response {
|
||
|
|
let file_path = state.static_dir.join(path.0);
|
||
|
|
// Security: prevent directory traversal
|
||
|
|
let canonical = match file_path.canonicalize() {
|
||
|
|
Ok(p) => p,
|
||
|
|
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
|
||
|
|
};
|
||
|
|
if !canonical.starts_with(&state.static_dir) {
|
||
|
|
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||
|
|
}
|
||
|
|
|
||
|
|
match fs::read(&canonical).await {
|
||
|
|
Ok(data) => {
|
||
|
|
let mime = mime_guess::from_path(&canonical).first_or_octet_stream();
|
||
|
|
Response::builder()
|
||
|
|
.status(200)
|
||
|
|
.header("Content-Type", mime.to_string())
|
||
|
|
.body(axum::body::Body::from(data))
|
||
|
|
.unwrap()
|
||
|
|
.into_response()
|
||
|
|
}
|
||
|
|
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Run the web frontend server.
|
||
|
|
pub async fn run_server(port: u16, static_dir: Option<PathBuf>) -> anyhow::Result<()> {
|
||
|
|
let dir = static_dir.unwrap_or_else(|| {
|
||
|
|
let p = PathBuf::from("apps/interfaces/web/dist");
|
||
|
|
if p.exists() {
|
||
|
|
p
|
||
|
|
} else {
|
||
|
|
warn!("No static dir found at {:?}, using current dir", p);
|
||
|
|
PathBuf::from(".")
|
||
|
|
}
|
||
|
|
});
|
||
|
|
let state = Arc::new(WebState { static_dir: dir });
|
||
|
|
let app = build_router(state);
|
||
|
|
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||
|
|
info!("Web frontend server listening on http://{addr}");
|
||
|
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||
|
|
axum::serve(listener, app).await?;
|
||
|
|
Ok(())
|
||
|
|
}
|