minco-workbench 1.2.1

Optional local dashboard and deterministic exports for bounded Minco project views
Documentation
use crate::{WorkbenchError, render_mermaid};
use axum::http as axum_http;
use axum::{Router, body::Body, extract::State, routing::get};
use axum_http::{
    HeaderMap, Response, StatusCode, Uri,
    header::{
        CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, HOST, ORIGIN, REFERRER_POLICY,
        X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
    },
};
use minco_project_view::ProjectView;
use std::{net::Ipv4Addr, sync::Arc};
use tokio::net::TcpListener;

const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";

struct ServerState {
    authority: String,
    origin: String,
    project_view: Vec<u8>,
    mermaid: Vec<u8>,
}

pub async fn bind_loopback(port: u16) -> Result<TcpListener, WorkbenchError> {
    TcpListener::bind((Ipv4Addr::LOCALHOST, port))
        .await
        .map_err(|source| WorkbenchError::Io {
            operation: "bind loopback workbench server",
            path: format!("127.0.0.1:{port}").into(),
            source,
        })
}

pub async fn serve_loopback(
    listener: TcpListener,
    view: ProjectView,
) -> Result<(), WorkbenchError> {
    let address = listener.local_addr().map_err(|source| WorkbenchError::Io {
        operation: "read workbench listener address",
        path: "loopback-listener".into(),
        source,
    })?;
    if !address.ip().is_loopback() {
        return Err(WorkbenchError::Io {
            operation: "validate workbench listener address",
            path: address.to_string().into(),
            source: std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                "workbench listener is not loopback",
            ),
        });
    }
    let project_view = serde_json::to_vec(&view)?;
    let state = Arc::new(ServerState {
        authority: address.to_string(),
        origin: format!("http://{address}"),
        project_view,
        mermaid: render_mermaid(&view).into_bytes(),
    });
    let router = Router::new()
        .route("/", get(asset))
        .route("/index.html", get(asset))
        .route("/workbench.css", get(asset))
        .route("/workbench.js", get(asset))
        .route("/project-view.json", get(asset))
        .route("/project-view.mmd", get(asset))
        .with_state(state);

    axum::serve(listener, router)
        .await
        .map_err(|source| WorkbenchError::Io {
            operation: "serve loopback workbench",
            path: address.to_string().into(),
            source,
        })
}

async fn asset(
    State(state): State<Arc<ServerState>>,
    headers: HeaderMap,
    uri: Uri,
) -> Response<Body> {
    if headers.get(HOST).and_then(|value| value.to_str().ok()) != Some(&state.authority) {
        return response(
            StatusCode::MISDIRECTED_REQUEST,
            "text/plain; charset=utf-8",
            b"non-loopback Host rejected".to_vec(),
        );
    }
    if !headers
        .get(ORIGIN)
        .is_none_or(|value| value.to_str().is_ok_and(|origin| origin == state.origin))
    {
        return response(
            StatusCode::FORBIDDEN,
            "text/plain; charset=utf-8",
            b"cross-origin request rejected".to_vec(),
        );
    }

    let (content_type, body) = match uri.path() {
        "/" | "/index.html" => (
            "text/html; charset=utf-8",
            include_bytes!("../assets/index.html").to_vec(),
        ),
        "/workbench.css" => (
            "text/css; charset=utf-8",
            include_bytes!("../assets/workbench.css").to_vec(),
        ),
        "/workbench.js" => (
            "text/javascript; charset=utf-8",
            include_bytes!("../assets/workbench.js").to_vec(),
        ),
        "/project-view.json" => ("application/json", state.project_view.clone()),
        "/project-view.mmd" => ("text/plain; charset=utf-8", state.mermaid.clone()),
        _ => {
            return response(
                StatusCode::NOT_FOUND,
                "text/plain; charset=utf-8",
                b"not found".to_vec(),
            );
        }
    };
    response(StatusCode::OK, content_type, body)
}

fn response(status: StatusCode, content_type: &'static str, body: Vec<u8>) -> Response<Body> {
    Response::builder()
        .status(status)
        .header(CONTENT_TYPE, content_type)
        .header(CACHE_CONTROL, "no-store")
        .header(CONTENT_SECURITY_POLICY, CSP)
        .header(X_CONTENT_TYPE_OPTIONS, "nosniff")
        .header(X_FRAME_OPTIONS, "DENY")
        .header(REFERRER_POLICY, "no-referrer")
        .body(Body::from(body))
        .expect("static workbench response headers are valid")
}