kovra-webui 0.9.0

kovra on-demand loopback Web UI — sensitivity-governed vault visualization; ships the `kovra-ui` container entrypoint.
Documentation
//! Static front-end assets, embedded into the binary (KOV-29).
//!
//! The Web UI ships **no build step and no runtime CDN**: the vendored Tabulator
//! grid and the first-party `app.js`/`app.css` are compiled into the binary via
//! [`include_str!`], so `kovra ui` is a single self-contained binary that works
//! offline and inside the L11 Docker image unchanged (I9/I10). Updating an asset
//! is a reviewed dependency bump — see `assets/VENDORED.md` for pinned versions
//! and SHA-256/SRI.
//!
//! These routes carry **no secrets**, so they sit *outside* the `/api` session
//! layer (a browser cannot attach the `x-kovra-session` header to a
//! `<script src>` / `<link href>` load) but *inside* the loopback guard, like
//! every other route.

use axum::{
    Router,
    http::header,
    response::{IntoResponse, Response},
    routing::get,
};

use crate::AppState;

const TABULATOR_JS: &str = include_str!("../assets/tabulator/tabulator.min.js");
const TABULATOR_CSS: &str = include_str!("../assets/tabulator/tabulator.min.css");
const APP_JS: &str = include_str!("../assets/app.js");
const APP_CSS: &str = include_str!("../assets/app.css");

// Brand assets, vendored on purpose (no runtime CDN — see `assets/VENDORED.md`):
// the cobra/keyhole marks (vector SVG — the squircle app icon for the sidebar
// logo, the icon mark for the favicon) and the Sora/Inter latin-subset woff2
// faces. SVG is text (`include_str!`); fonts are binary (`include_bytes!`).
const APPICON_SVG: &str = include_str!("../assets/kovra-appicon.svg");
const ICONMARK_SVG: &str = include_str!("../assets/kovra-iconmark.svg");
// The colored cobra mark (raster), used as the sidebar brand logo.
const MARK_COLOR_PNG: &[u8] = include_bytes!("../assets/kovra-mark-color.png");
const SORA_600: &[u8] = include_bytes!("../assets/fonts/sora-latin-600-normal.woff2");
const INTER_400: &[u8] = include_bytes!("../assets/fonts/inter-latin-400-normal.woff2");
const INTER_500: &[u8] = include_bytes!("../assets/fonts/inter-latin-500-normal.woff2");
const INTER_600: &[u8] = include_bytes!("../assets/fonts/inter-latin-600-normal.woff2");

const JS: &str = "text/javascript; charset=utf-8";
const CSS: &str = "text/css; charset=utf-8";
const SVG: &str = "image/svg+xml; charset=utf-8";
const PNG: &str = "image/png";
const WOFF2: &str = "font/woff2";

/// The `/assets/*` static routes (vendored Tabulator + first-party app shell +
/// brand icon + brand fonts).
pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/assets/tabulator/tabulator.min.js", get(tabulator_js))
        .route("/assets/tabulator/tabulator.min.css", get(tabulator_css))
        .route("/assets/app.js", get(app_js))
        .route("/assets/app.css", get(app_css))
        .route("/assets/kovra-appicon.svg", get(appicon_svg))
        .route("/assets/kovra-iconmark.svg", get(iconmark_svg))
        .route("/assets/kovra-mark-color.png", get(mark_color_png))
        .route("/assets/fonts/sora-latin-600-normal.woff2", get(sora_600))
        .route("/assets/fonts/inter-latin-400-normal.woff2", get(inter_400))
        .route("/assets/fonts/inter-latin-500-normal.woff2", get(inter_500))
        .route("/assets/fonts/inter-latin-600-normal.woff2", get(inter_600))
}

/// Dev-only hot-reload (compiled out of release builds via `debug_assertions`):
/// when `KOVRA_WEBUI_DEV=<dir>` is set, serve the named first-party asset from
/// that directory on disk instead of the embedded copy, so `app.js`/`app.css`
/// edits show on a browser refresh with no rebuild. Returns `None` (→ embedded)
/// when the var is unset or the file is unreadable. Reads only fixed,
/// caller-supplied asset names joined to the dev dir — never request input.
#[cfg(debug_assertions)]
fn dev_disk(name: &str) -> Option<String> {
    let dir = std::env::var_os("KOVRA_WEBUI_DEV")?;
    std::fs::read_to_string(std::path::Path::new(&dir).join(name)).ok()
}

/// A first-party text asset, served from disk in dev (`dev_disk`) when enabled,
/// else from the embedded copy. Returns an owned [`Response`] so both branches
/// share one return type.
fn text_asset(content_type: &'static str, embedded: &'static str, _name: &str) -> Response {
    #[cfg(debug_assertions)]
    if let Some(body) = dev_disk(_name) {
        return (
            [
                (header::CONTENT_TYPE, content_type),
                (header::CACHE_CONTROL, "no-store"),
            ],
            body,
        )
            .into_response();
    }
    asset(content_type, embedded).into_response()
}

/// A static text asset response: the embedded body plus its content type.
/// `no-store` keeps the loopback/ephemeral session model honest — nothing is
/// cached to disk.
fn asset(content_type: &'static str, body: &'static str) -> impl IntoResponse {
    (
        [
            (header::CONTENT_TYPE, content_type),
            (header::CACHE_CONTROL, "no-store"),
        ],
        body,
    )
}

/// A static binary asset response (icon / fonts). Same caching posture as
/// [`asset`]; the body is an embedded byte slice.
fn binary(content_type: &'static str, body: &'static [u8]) -> impl IntoResponse {
    (
        [
            (header::CONTENT_TYPE, content_type),
            (header::CACHE_CONTROL, "no-store"),
        ],
        body,
    )
}

async fn tabulator_js() -> impl IntoResponse {
    asset(JS, TABULATOR_JS)
}
async fn tabulator_css() -> impl IntoResponse {
    asset(CSS, TABULATOR_CSS)
}
async fn app_js() -> Response {
    text_asset(JS, APP_JS, "app.js")
}
async fn app_css() -> Response {
    text_asset(CSS, APP_CSS, "app.css")
}
async fn appicon_svg() -> Response {
    text_asset(SVG, APPICON_SVG, "kovra-appicon.svg")
}
async fn iconmark_svg() -> Response {
    text_asset(SVG, ICONMARK_SVG, "kovra-iconmark.svg")
}
async fn mark_color_png() -> impl IntoResponse {
    binary(PNG, MARK_COLOR_PNG)
}
async fn sora_600() -> impl IntoResponse {
    binary(WOFF2, SORA_600)
}
async fn inter_400() -> impl IntoResponse {
    binary(WOFF2, INTER_400)
}
async fn inter_500() -> impl IntoResponse {
    binary(WOFF2, INTER_500)
}
async fn inter_600() -> impl IntoResponse {
    binary(WOFF2, INTER_600)
}