1use crate::auth_api::error;
21use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
22use axum::response::{IntoResponse, Response};
23
24struct Asset {
26 path: &'static str,
27 body: &'static [u8],
28 content_type: &'static str,
29 etag: &'static str,
30}
31
32include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
33
34const IMMUTABLE: &str = "public, max-age=31536000, immutable";
36
37pub async fn serve(method: Method, uri: Uri, headers: HeaderMap) -> Response {
39 serve_assets(method, uri, headers, FILES)
40}
41
42fn serve_assets(method: Method, uri: Uri, headers: HeaderMap, assets: &'static [Asset]) -> Response {
43 let find = |path: &str| assets.iter().find(|a| a.path == path);
44 let path = uri.path();
45 if is_reserved(path) || (method != Method::GET && method != Method::HEAD) {
46 let mut response = error(StatusCode::NOT_FOUND, "not_found", "no such route");
47 response
48 .headers_mut()
49 .insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
50 return response;
51 }
52 if let Some(asset) = find(path) {
53 let cache = if path.starts_with("/assets/") {
54 IMMUTABLE
55 } else {
56 "no-cache"
57 };
58 return file(asset, cache, &headers);
59 }
60 if path.starts_with("/assets/") || matches!(path, "/sw.js" | "/manifest.webmanifest") {
61 return (
62 StatusCode::NOT_FOUND,
63 [(header::X_CONTENT_TYPE_OPTIONS, "nosniff")],
64 "not found",
65 )
66 .into_response();
67 }
68 let index = find("/index.html").expect("the build has an index.html");
69 if is_setup(path) {
70 file(index, "no-store", &HeaderMap::new())
71 } else {
72 file(index, "no-cache", &headers)
73 }
74}
75
76fn is_reserved(path: &str) -> bool {
78 path == "/api" || path.starts_with("/api/") || path.starts_with("/mcp/")
79}
80
81fn is_setup(path: &str) -> bool {
83 path == "/setup" || path.starts_with("/setup/")
84}
85
86#[cfg(test)]
87fn find(path: &str) -> Option<&'static Asset> {
88 FILES.iter().find(|a| a.path == path)
89}
90
91fn file(asset: &'static Asset, cache: &'static str, request: &HeaderMap) -> Response {
92 let fresh = cache == "no-cache"
93 && request
94 .get(header::IF_NONE_MATCH)
95 .and_then(|v| v.to_str().ok())
96 .is_some_and(|tags| tags.split(',').any(|t| t.trim() == asset.etag || t.trim() == "*"));
97 let mut response = if fresh {
99 StatusCode::NOT_MODIFIED.into_response()
100 } else {
101 ([(header::CONTENT_TYPE, asset.content_type)], asset.body).into_response()
102 };
103 let headers = response.headers_mut();
104 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
105 headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
106 headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
107 if asset.path == "/sw.js" {
108 headers.insert(
109 header::CONTENT_SECURITY_POLICY,
110 HeaderValue::from_static(crate::csp::WORKER_POLICY),
111 );
112 }
113 if cache != "no-store" {
114 headers.insert(header::ETAG, HeaderValue::from_static(asset.etag));
115 }
116 response
117}
118
119pub fn ui_embedded() -> bool {
121 EMBEDDED
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn the_api_and_the_gateways_proxy_are_reserved() {
130 for path in ["/api", "/api/", "/api/nope", "/mcp/slug", "/mcp/"] {
131 assert!(is_reserved(path), "{path}");
132 }
133 for path in ["/", "/apis", "/mcp", "/sessions/s-1", "/setup"] {
134 assert!(!is_reserved(path), "{path}");
135 }
136 }
137
138 #[test]
139 fn the_build_has_an_index() {
140 let index = find("/index.html").unwrap();
141 assert_eq!(index.content_type, "text/html; charset=utf-8");
142 assert!(index.etag.starts_with('"') && index.etag.len() == 18, "{}", index.etag);
143 }
144
145 #[test]
146 fn missing_worker_and_manifest_do_not_fall_back_to_html() {
147 static INDEX_ONLY: &[Asset] = &[Asset {
148 path: "/index.html",
149 body: b"index",
150 content_type: "text/html; charset=utf-8",
151 etag: "\"index\"",
152 }];
153 for path in ["/sw.js", "/manifest.webmanifest"] {
154 let response = serve_assets(Method::GET, path.parse().unwrap(), HeaderMap::new(), INDEX_ONLY);
155 assert_eq!(response.status(), StatusCode::NOT_FOUND);
156 }
157 }
158
159 #[test]
160 fn a_worker_response_has_its_own_policy() {
161 static WORKER: Asset = Asset {
162 path: "/sw.js",
163 body: b"worker",
164 content_type: "text/javascript; charset=utf-8",
165 etag: "\"worker\"",
166 };
167 let response = file(&WORKER, "no-cache", &HeaderMap::new());
168 assert_eq!(
169 response.headers()[header::CONTENT_SECURITY_POLICY],
170 crate::csp::WORKER_POLICY
171 );
172 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
173 assert_eq!(response.headers()[header::ETAG], "\"worker\"");
174 assert!(crate::csp::WORKER_POLICY.contains("img-src 'self'"));
175 assert!(crate::csp::POLICY.contains("worker-src 'self'") && crate::csp::POLICY.contains("manifest-src 'self'"));
176 }
177}