//! 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) -> 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>, ) -> Result, 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#" Zesdex Web

Zesdex Web

Web interface is ready.

To connect the frontend:

  1. Build the frontend: cd apps/interfaces/web && npm install && npm run build
  2. Restart with --web-dir apps/interfaces/web/dist
"#.to_string(), )) } } } /// Serve static files from the configured directory. async fn static_handler( axum::extract::State(state): axum::extract::State>, path: axum::extract::Path, ) -> 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) -> 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(()) }