feat(server): serve dashboard SPA with deep-link fallback; remove v1 portal
Axum now serves the v2 React/Vite dashboard SPA at / with a client-side routing fallback, and the dead v1 HTML portal is removed (nothing was live on the server to preserve). - SPA served from server/static/app via ServeDir with a fallback to index.html, so deep links (/machines, /sessions) resolve to the SPA. - /api/*rest and /ws/*rest return JSON 404 so unrouted API/WS paths never leak index.html to clients; real /api, /ws, /health, /metrics, and the /downloads nest keep precedence (matchit static-over-wildcard). - Path-aware Cache-Control: hashed /assets immutable, index.html no-cache. - Vite builds to server/static/app (base /); the artifact is gitignored and rebuilt at deploy time (npm ci && npm run build). - Removed v1 portal files (login/dashboard/users/index/viewer .html) and their dead serve_* handlers; the SPA owns /, /login, /dashboard, /users. Verified locally: server boots, / and deep links serve the SPA, unknown /api path returns JSON 404 (not HTML), /health and /downloads intact. cargo build + clippy -D warnings green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,20 +24,36 @@ use axum::{
|
||||
extract::{ConnectInfo, Json, Path, Query, Request, State},
|
||||
http::StatusCode,
|
||||
middleware::{self as axum_middleware, Next},
|
||||
response::{Html, IntoResponse},
|
||||
routing::{delete, get, post, put},
|
||||
response::IntoResponse,
|
||||
routing::{any, delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist};
|
||||
|
||||
/// Root of the static asset tree, relative to the server's working directory.
|
||||
/// Holds the agent `downloads/` tree AND the v2 SPA build under `app/`.
|
||||
const STATIC_DIR: &str = "static";
|
||||
|
||||
/// Directory the React/Vite SPA is built into (`dashboard/` Vite `build.outDir`
|
||||
/// points here). The Axum `fallback_service` serves this tree at the server
|
||||
/// root, so `npm run build` lands the SPA exactly where it is served — no copy
|
||||
/// step. A dedicated subdir so the Vite build's `emptyOutDir` clears only the
|
||||
/// SPA, never the agent downloads tree in the static root.
|
||||
const SPA_DIR: &str = "static/app";
|
||||
|
||||
/// The SPA entry document. Returned (with 200) for any unmatched, non-API,
|
||||
/// non-WS, non-asset GET so `BrowserRouter` deep links (`/machines`,
|
||||
/// `/sessions`, `/login`) survive a hard reload.
|
||||
const SPA_INDEX: &str = "static/app/index.html";
|
||||
use metrics::SharedMetrics;
|
||||
use prometheus_client::registry::Registry;
|
||||
use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager};
|
||||
@@ -413,15 +429,43 @@ async fn main() -> Result<()> {
|
||||
get(api::downloads::download_support),
|
||||
)
|
||||
.route("/api/download/agent", get(api::downloads::download_agent))
|
||||
// HTML page routes (clean URLs)
|
||||
.route("/login", get(serve_login))
|
||||
.route("/dashboard", get(serve_dashboard))
|
||||
.route("/users", get(serve_users))
|
||||
// Namespace 404 guards. These wildcard routes catch any /api/* or /ws/*
|
||||
// path that no explicit route above matched, returning a JSON 404 so the
|
||||
// SPA fallback_service never answers an API/WS path with index.html. They
|
||||
// are intentionally the LEAST specific routes in each namespace: matchit
|
||||
// (axum 0.7) prefers a static segment over a `*` capture, so every real
|
||||
// route above still wins. `any(...)` covers every method (a bad WS path
|
||||
// is a GET, but POST/PUT/etc. to a dead /api/* path must 404 too, not 405).
|
||||
.route("/api/*rest", any(api_not_found))
|
||||
.route("/ws/*rest", any(api_not_found))
|
||||
// Public agent download tree (e.g. /downloads/guruconnect.exe). Mounted
|
||||
// explicitly so it keeps working after the v2 SPA takes over the root
|
||||
// fallback below — CLAUDE.md documents this as the public download URL.
|
||||
// `nest_service` is matched BEFORE `fallback_service`, so these binaries
|
||||
// are served from disk and never fall through to the SPA index.html.
|
||||
.nest_service("/downloads", ServeDir::new(format!("{STATIC_DIR}/downloads")))
|
||||
// NOTE: there are intentionally no /login, /dashboard, /users routes.
|
||||
// The v2 SPA (BrowserRouter) owns those paths and resolves them via the
|
||||
// fallback_service below; registering server-side handlers for them would
|
||||
// shadow the SPA on a hard reload.
|
||||
// State and middleware
|
||||
.with_state(state.clone())
|
||||
.layer(axum_middleware::from_fn_with_state(state, auth_layer))
|
||||
// Serve static files for portal (fallback)
|
||||
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true))
|
||||
// SPA fallback: serve the React/Vite build from SPA_DIR and, for any
|
||||
// unmatched path, return the SPA index.html WITH 200 (via `.fallback`,
|
||||
// not `.not_found_service` which would force a 404) so BrowserRouter
|
||||
// deep links resolve. This is the Router's `fallback_service`, so it runs
|
||||
// ONLY after every explicit /api/*, /ws/*, /health, /metrics route and
|
||||
// the /downloads nest fail to match. An unknown /api/... path therefore
|
||||
// never reaches here — it hits the per-router 404 and returns the normal
|
||||
// (non-HTML) 404 the typed client expects. Real assets under /assets/*
|
||||
// are served from disk by ServeDir with correct content-types; only
|
||||
// genuinely missing files fall through to index.html.
|
||||
.fallback_service(
|
||||
ServeDir::new(SPA_DIR)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new(SPA_INDEX)),
|
||||
)
|
||||
// Middleware
|
||||
.layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12
|
||||
.layer(TraceLayer::new_for_http())
|
||||
@@ -474,6 +518,23 @@ async fn health() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
/// Explicit 404 for unmatched paths under the `/api` and `/ws` namespaces.
|
||||
///
|
||||
/// CRITICAL: without these catch-all routes, an unknown `/api/...` or `/ws/...`
|
||||
/// path would fall through to the SPA `fallback_service` and be answered with
|
||||
/// `index.html` (HTTP 200, text/html). That would mask real 404s and break the
|
||||
/// dashboard's typed client, which parses a JSON error envelope from API 404s.
|
||||
/// These routes are LESS specific than every real `/api/...` / `/ws/...` route
|
||||
/// (matchit matches a static segment before a `*` capture), so they only catch
|
||||
/// genuinely-unrouted API/WS paths and return a proper JSON 404 — never HTML.
|
||||
async fn api_not_found() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
r#"{"error":"Not Found","status_code":404}"#,
|
||||
)
|
||||
}
|
||||
|
||||
/// Prometheus metrics endpoint
|
||||
async fn prometheus_metrics(State(state): State<AppState>) -> String {
|
||||
use prometheus_client::encoding::text::encode;
|
||||
@@ -849,24 +910,3 @@ async fn trigger_machine_update(
|
||||
}
|
||||
}
|
||||
|
||||
// Static page handlers
|
||||
async fn serve_login() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/login.html").await {
|
||||
Ok(content) => Html(content).into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_dashboard() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/dashboard.html").await {
|
||||
Ok(content) => Html(content).into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_users() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/users.html").await {
|
||||
Ok(content) => Html(content).into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user