Skip to main content

minco_workbench/
server.rs

1use crate::{WorkbenchError, render_mermaid};
2use axum::http as axum_http;
3use axum::{Router, body::Body, extract::State, routing::get};
4use axum_http::{
5    HeaderMap, Response, StatusCode, Uri,
6    header::{
7        CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, HOST, ORIGIN, REFERRER_POLICY,
8        X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
9    },
10};
11use minco_project_view::ProjectView;
12use std::{net::Ipv4Addr, sync::Arc};
13use tokio::net::TcpListener;
14
15const 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'";
16
17struct ServerState {
18    authority: String,
19    origin: String,
20    project_view: Vec<u8>,
21    mermaid: Vec<u8>,
22}
23
24pub async fn bind_loopback(port: u16) -> Result<TcpListener, WorkbenchError> {
25    TcpListener::bind((Ipv4Addr::LOCALHOST, port))
26        .await
27        .map_err(|source| WorkbenchError::Io {
28            operation: "bind loopback workbench server",
29            path: format!("127.0.0.1:{port}").into(),
30            source,
31        })
32}
33
34pub async fn serve_loopback(
35    listener: TcpListener,
36    view: ProjectView,
37) -> Result<(), WorkbenchError> {
38    let address = listener.local_addr().map_err(|source| WorkbenchError::Io {
39        operation: "read workbench listener address",
40        path: "loopback-listener".into(),
41        source,
42    })?;
43    if !address.ip().is_loopback() {
44        return Err(WorkbenchError::Io {
45            operation: "validate workbench listener address",
46            path: address.to_string().into(),
47            source: std::io::Error::new(
48                std::io::ErrorKind::PermissionDenied,
49                "workbench listener is not loopback",
50            ),
51        });
52    }
53    let project_view = serde_json::to_vec(&view)?;
54    let state = Arc::new(ServerState {
55        authority: address.to_string(),
56        origin: format!("http://{address}"),
57        project_view,
58        mermaid: render_mermaid(&view).into_bytes(),
59    });
60    let router = Router::new()
61        .route("/", get(asset))
62        .route("/index.html", get(asset))
63        .route("/workbench.css", get(asset))
64        .route("/workbench.js", get(asset))
65        .route("/project-view.json", get(asset))
66        .route("/project-view.mmd", get(asset))
67        .with_state(state);
68
69    axum::serve(listener, router)
70        .await
71        .map_err(|source| WorkbenchError::Io {
72            operation: "serve loopback workbench",
73            path: address.to_string().into(),
74            source,
75        })
76}
77
78async fn asset(
79    State(state): State<Arc<ServerState>>,
80    headers: HeaderMap,
81    uri: Uri,
82) -> Response<Body> {
83    if headers.get(HOST).and_then(|value| value.to_str().ok()) != Some(&state.authority) {
84        return response(
85            StatusCode::MISDIRECTED_REQUEST,
86            "text/plain; charset=utf-8",
87            b"non-loopback Host rejected".to_vec(),
88        );
89    }
90    if !headers
91        .get(ORIGIN)
92        .is_none_or(|value| value.to_str().is_ok_and(|origin| origin == state.origin))
93    {
94        return response(
95            StatusCode::FORBIDDEN,
96            "text/plain; charset=utf-8",
97            b"cross-origin request rejected".to_vec(),
98        );
99    }
100
101    let (content_type, body) = match uri.path() {
102        "/" | "/index.html" => (
103            "text/html; charset=utf-8",
104            include_bytes!("../assets/index.html").to_vec(),
105        ),
106        "/workbench.css" => (
107            "text/css; charset=utf-8",
108            include_bytes!("../assets/workbench.css").to_vec(),
109        ),
110        "/workbench.js" => (
111            "text/javascript; charset=utf-8",
112            include_bytes!("../assets/workbench.js").to_vec(),
113        ),
114        "/project-view.json" => ("application/json", state.project_view.clone()),
115        "/project-view.mmd" => ("text/plain; charset=utf-8", state.mermaid.clone()),
116        _ => {
117            return response(
118                StatusCode::NOT_FOUND,
119                "text/plain; charset=utf-8",
120                b"not found".to_vec(),
121            );
122        }
123    };
124    response(StatusCode::OK, content_type, body)
125}
126
127fn response(status: StatusCode, content_type: &'static str, body: Vec<u8>) -> Response<Body> {
128    Response::builder()
129        .status(status)
130        .header(CONTENT_TYPE, content_type)
131        .header(CACHE_CONTROL, "no-store")
132        .header(CONTENT_SECURITY_POLICY, CSP)
133        .header(X_CONTENT_TYPE_OPTIONS, "nosniff")
134        .header(X_FRAME_OPTIONS, "DENY")
135        .header(REFERRER_POLICY, "no-referrer")
136        .body(Body::from(body))
137        .expect("static workbench response headers are valid")
138}