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}