Skip to main content

orbit_runtime/
ui.rs

1//! The built-in operations UI (`/ui/`).
2//!
3//! Static HTML, CSS and JavaScript compiled into the binary. The assets hold
4//! no data and no secrets, so they are served without authentication; the
5//! page asks for a token and calls the HTTP API (`/v1/*`) with it, so the
6//! API's own authentication and authorization decide what each caller sees.
7//!
8//! The router is merged outside the API's authentication and cluster layers:
9//! a standby or shared-mode node serves the UI itself, and its API answers
10//! the reads exactly as it would for any other client.
11
12use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
13use axum::response::{IntoResponse, Response};
14use axum::routing::get;
15use axum::Router;
16
17const INDEX_HTML: &str = include_str!("../ui/index.html");
18const APP_JS: &str = include_str!("../ui/app.js");
19const APP_CSS: &str = include_str!("../ui/app.css");
20const FAVICON_SVG: &str = include_str!("../ui/favicon.svg");
21
22/// Content-Security-Policy of every UI response. Scripts and styles come only
23/// from the separate same-origin assets (no inline script or style), the page
24/// talks only to its own origin, and it cannot be framed.
25pub const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self'; \
26img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; \
27form-action 'none'; frame-ancestors 'none'";
28
29/// Routes of the UI: `/ui/` and its assets, and `/` (a redirect to `/ui/`
30/// for browsers).
31pub fn router() -> Router {
32    Router::new()
33        .route("/", get(root))
34        .route("/ui", get(|| async { redirect("/ui/") }))
35        .route(
36            "/ui/",
37            get(|| async { asset("text/html; charset=utf-8", INDEX_HTML) }),
38        )
39        .route(
40            "/ui/index.html",
41            get(|| async { asset("text/html; charset=utf-8", INDEX_HTML) }),
42        )
43        .route(
44            "/ui/app.js",
45            get(|| async { asset("text/javascript; charset=utf-8", APP_JS) }),
46        )
47        .route(
48            "/ui/app.css",
49            get(|| async { asset("text/css; charset=utf-8", APP_CSS) }),
50        )
51        .route(
52            "/ui/favicon.svg",
53            get(|| async { asset("image/svg+xml", FAVICON_SVG) }),
54        )
55        .route("/ui/{*rest}", get(not_found))
56}
57
58fn security_headers(headers: &mut HeaderMap) {
59    let set = |h: &mut HeaderMap, k: &'static str, v: &'static str| {
60        h.insert(k, HeaderValue::from_static(v));
61    };
62    set(headers, "content-security-policy", CSP);
63    set(headers, "x-content-type-options", "nosniff");
64    set(headers, "x-frame-options", "DENY");
65    set(headers, "referrer-policy", "no-referrer");
66    set(headers, "cross-origin-opener-policy", "same-origin");
67    set(headers, "cross-origin-resource-policy", "same-origin");
68    set(
69        headers,
70        "permissions-policy",
71        "camera=(), microphone=(), geolocation=(), payment=()",
72    );
73    // The assets change with the binary; revalidate rather than cache stale copies.
74    set(headers, "cache-control", "no-cache");
75}
76
77fn asset(content_type: &'static str, body: &'static str) -> Response {
78    let mut resp = (StatusCode::OK, body).into_response();
79    resp.headers_mut()
80        .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
81    security_headers(resp.headers_mut());
82    resp
83}
84
85fn redirect(to: &'static str) -> Response {
86    let mut resp = StatusCode::FOUND.into_response();
87    resp.headers_mut()
88        .insert(header::LOCATION, HeaderValue::from_static(to));
89    security_headers(resp.headers_mut());
90    resp
91}
92
93async fn not_found() -> Response {
94    let mut resp = (StatusCode::NOT_FOUND, "not found\n").into_response();
95    resp.headers_mut().insert(
96        header::CONTENT_TYPE,
97        HeaderValue::from_static("text/plain; charset=utf-8"),
98    );
99    security_headers(resp.headers_mut());
100    resp
101}
102
103/// `GET /`: browsers (`Accept: text/html`) are sent to the UI; API clients
104/// get the same JSON 404 as any other unknown path.
105async fn root(headers: HeaderMap) -> Response {
106    let html = headers
107        .get_all(header::ACCEPT)
108        .iter()
109        .filter_map(|v| v.to_str().ok())
110        .any(|v| v.contains("text/html"));
111    if html {
112        return redirect("/ui/");
113    }
114    (
115        StatusCode::NOT_FOUND,
116        axum::Json(serde_json::json!({ "error": {
117            "code": "NOT_FOUND",
118            "message": "no such endpoint; the API is under /v1/ and the web UI under /ui/",
119        }})),
120    )
121        .into_response()
122}