termstage 0.2.0

Local browser terminal presentation tool for live demos.
//! First-party browser terminal assets.
//!
//! The frontend bundle is generated by `make frontend-build` from
//! `apps/server/web`. The binary embeds the built files so production requests do
//! not fetch scripts or styles from a CDN.

use axum::{
    body::Body,
    http::{
        Response, StatusCode,
        header::{CONTENT_TYPE, HeaderValue},
    },
    response::IntoResponse,
};
use termstage_core::security::BasePath;

const INDEX_HTML: &str = include_str!("../web/dist/index.html");
const APP_JS: &[u8] = include_bytes!("../web/dist/assets/index.js");
const APP_CSS: &str = include_str!("../web/dist/assets/index.css");
const BASE_HREF_PLACEHOLDER: &str = "<!--TERMSTAGE_BASE_HREF-->";

/// Serves the browser terminal HTML document.
///
/// When `base_path` is set, a `<base href="…/">` element is injected so the
/// document's relative asset and WebSocket URLs resolve against the upstream
/// reverse-proxy prefix instead of the proxied origin's root.
#[must_use]
pub fn index_response(base_path: Option<&BasePath>) -> Response<Body> {
    let body: Body = match base_path {
        Some(prefix) => INDEX_HTML
            .replace(
                BASE_HREF_PLACEHOLDER,
                &format!("<base href=\"{}\">", html_escape_attribute(prefix.as_str())),
            )
            .into(),
        None => Body::from(INDEX_HTML),
    };
    response("text/html; charset=utf-8", body)
}

/// Serves a bundled static asset.
#[must_use]
pub fn asset_response(path: &str) -> Response<Body> {
    match path {
        "index.js" => response("text/javascript; charset=utf-8", Body::from(APP_JS)),
        "index.css" => response("text/css; charset=utf-8", Body::from(APP_CSS)),
        _ => StatusCode::NOT_FOUND.into_response(),
    }
}

fn html_escape_attribute(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for byte in value.bytes() {
        match byte {
            b'&' => out.push_str("&amp;"),
            b'<' => out.push_str("&lt;"),
            b'>' => out.push_str("&gt;"),
            b'"' => out.push_str("&quot;"),
            b'\'' => out.push_str("&#39;"),
            _ => out.push(byte as char),
        }
    }
    out
}

fn response(content_type: &'static str, body: Body) -> Response<Body> {
    let mut response = Response::new(body);
    response
        .headers_mut()
        .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
    response
}