1use crate::server::Routes;
19use crate::server::http::{Request, Response};
20
21mod assets {
22 include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
23}
24
25pub const BUILT: bool = assets::BUILT;
28
29pub const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
34img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; \
35form-action 'self'; frame-ancestors 'none'";
36
37pub fn routes() -> Routes {
39 std::sync::Arc::new(|r: &Request| serve(assets::ASSETS, r))
40}
41
42pub fn is_api_path(path: &str) -> bool {
45 let under = |p: &str, prefix: &str| p == prefix || p.starts_with(&format!("{prefix}/"));
46 if under(path, "/api") || under(path, "/mcp") || under(path, "/healthz") {
47 return true;
48 }
49 if let Some(rest) = path.strip_prefix("/orgs/") {
50 if let Some((_, tail)) = rest.split_once('/') {
51 return under(&format!("/{tail}"), "/mcp") || under(&format!("/{tail}"), "/api");
52 }
53 }
54 false
55}
56
57pub fn serve(assets: &[(&str, &[u8])], req: &Request) -> Option<Response> {
59 if is_api_path(&req.path) {
60 return None;
61 }
62 let head = req.method == "HEAD";
63 if req.method != "GET" && !head {
64 return Some(
65 secure(Response::text(405, "method not allowed")).header("Allow", "GET, HEAD"),
66 );
67 }
68 let find = |p: &str| {
69 assets
70 .binary_search_by(|(k, _)| k.cmp(&p))
71 .ok()
72 .map(|i| assets[i].1)
73 };
74 let path = if req.path == "/" {
75 "/index.html"
76 } else {
77 req.path.as_str()
78 };
79 let (path, body) = match find(path) {
80 Some(b) => (path, b),
81 None if path.starts_with("/assets/") || has_extension(path) => {
84 return Some(secure(Response::text(404, "not found")));
85 }
86 None => ("/index.html", find("/index.html")?),
87 };
88 let cache = if path == "/index.html" {
89 "no-store"
90 } else if path.starts_with("/assets/") {
91 "public, max-age=31536000, immutable"
92 } else {
93 "public, max-age=3600"
94 };
95 let r = Response::new(200)
96 .header("Content-Type", content_type(path))
97 .header("Cache-Control", cache);
98 Some(secure(if head { r } else { r.body(body.to_vec()) }))
99}
100
101fn has_extension(path: &str) -> bool {
102 path.rsplit('/').next().is_some_and(|f| f.contains('.'))
103}
104
105fn secure(r: Response) -> Response {
107 r.header("Content-Security-Policy", CSP)
108 .header("X-Frame-Options", "DENY")
109 .header("X-Content-Type-Options", "nosniff")
110 .header("Referrer-Policy", "same-origin")
111 .header("Cross-Origin-Opener-Policy", "same-origin")
112 .header(
113 "Permissions-Policy",
114 "camera=(), microphone=(), geolocation=(), payment=()",
115 )
116}
117
118fn content_type(path: &str) -> &'static str {
119 let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
120 match ext {
121 "html" => "text/html; charset=utf-8",
122 "js" | "mjs" => "text/javascript; charset=utf-8",
123 "css" => "text/css; charset=utf-8",
124 "json" => "application/json",
125 "webmanifest" => "application/manifest+json",
126 "svg" => "image/svg+xml",
127 "png" => "image/png",
128 "jpg" | "jpeg" => "image/jpeg",
129 "webp" => "image/webp",
130 "ico" => "image/x-icon",
131 "woff2" => "font/woff2",
132 "woff" => "font/woff",
133 "txt" => "text/plain; charset=utf-8",
134 "wasm" => "application/wasm",
135 _ => "application/octet-stream",
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::server::http::Peer;
143
144 const ASSETS: &[(&str, &[u8])] = &[
145 ("/assets/index-abc123.css", b"body{}"),
146 ("/assets/index-abc123.js", b"console.log(1)"),
147 ("/favicon-32.png", b"\x89PNG"),
148 ("/index.html", b"<!doctype html><title>isb</title>"),
149 ];
150
151 fn req(method: &str, path: &str) -> Request {
152 Request {
153 method: method.into(),
154 path: path.into(),
155 query: None,
156 headers: vec![],
157 body: vec![],
158 peer: Peer::Tcp("127.0.0.1:1".parse().unwrap()),
159 }
160 }
161
162 fn get(path: &str) -> Option<Response> {
163 serve(ASSETS, &req("GET", path))
164 }
165
166 #[test]
167 fn the_embedded_table_is_sorted_and_has_an_index() {
168 assert!(assets::ASSETS.windows(2).all(|w| w[0].0 < w[1].0));
169 assert!(assets::ASSETS.iter().any(|(p, _)| *p == "/index.html"));
170 }
171
172 #[test]
173 fn files_with_types_and_caching() {
174 let r = get("/assets/index-abc123.js").unwrap();
175 assert_eq!(r.status, 200);
176 assert_eq!(r.body, b"console.log(1)");
177 assert_eq!(
178 r.get_header("content-type"),
179 Some("text/javascript; charset=utf-8")
180 );
181 assert!(r.get_header("cache-control").unwrap().contains("immutable"));
182 let r = get("/assets/index-abc123.css").unwrap();
183 assert_eq!(
184 r.get_header("content-type"),
185 Some("text/css; charset=utf-8")
186 );
187 let r = get("/favicon-32.png").unwrap();
188 assert_eq!(r.get_header("content-type"), Some("image/png"));
189 assert_eq!(r.get_header("cache-control"), Some("public, max-age=3600"));
190 }
191
192 #[test]
193 fn spa_fallback_serves_index_uncached() {
194 for p in [
195 "/",
196 "/index.html",
197 "/login",
198 "/account/tokens",
199 "/invite",
200 "/orgs/ocai",
201 "/orgs/ocai/stacks/web",
202 ] {
203 let r = get(p).unwrap_or_else(|| panic!("{p} not served"));
204 assert_eq!(r.status, 200, "{p}");
205 assert_eq!(r.body, b"<!doctype html><title>isb</title>", "{p}");
206 assert_eq!(r.get_header("cache-control"), Some("no-store"), "{p}");
207 assert_eq!(
208 r.get_header("content-type"),
209 Some("text/html; charset=utf-8")
210 );
211 }
212 }
213
214 #[test]
215 fn missing_files_are_404_not_the_shell() {
216 for p in [
217 "/assets/gone-123.js",
218 "/assets/x",
219 "/robots.txt",
220 "/a/b.png",
221 ] {
222 assert_eq!(get(p).unwrap().status, 404, "{p}");
223 }
224 }
225
226 #[test]
227 fn api_paths_are_never_shadowed() {
228 for p in [
229 "/api",
230 "/api/",
231 "/api/v1/auth/me",
232 "/api/v1/tools/stack_list",
233 "/api/v1/openapi.json",
234 "/api/v1/events",
235 "/api/v2/anything",
236 "/mcp",
237 "/mcp/x",
238 "/healthz",
239 "/orgs/ocai/mcp",
240 "/orgs/ocai/api/v1/tools/stack_list",
241 "/orgs/ocai/api",
242 ] {
243 assert!(is_api_path(p), "{p}");
244 assert!(get(p).is_none(), "{p} answered by the UI");
245 assert!(serve(ASSETS, &req("POST", p)).is_none(), "POST {p}");
246 }
247 for p in [
248 "/",
249 "/login",
250 "/apix",
251 "/mcpx",
252 "/orgs/ocai",
253 "/orgs/ocai/apps",
254 ] {
255 assert!(!is_api_path(p), "{p}");
256 }
257 }
258
259 #[test]
260 fn security_headers_everywhere() {
261 for r in [
262 get("/").unwrap(),
263 get("/assets/index-abc123.js").unwrap(),
264 get("/assets/missing.js").unwrap(),
265 serve(ASSETS, &req("POST", "/login")).unwrap(),
266 ] {
267 let csp = r.get_header("content-security-policy").unwrap();
268 assert!(csp.contains("script-src 'self';"), "{csp}");
269 assert!(csp.contains("connect-src 'self'"));
270 assert!(csp.contains("frame-ancestors 'none'"));
271 assert!(!csp.contains("unsafe-eval"));
272 assert_eq!(r.get_header("x-frame-options"), Some("DENY"));
273 assert_eq!(r.get_header("referrer-policy"), Some("same-origin"));
274 assert_eq!(r.get_header("x-content-type-options"), Some("nosniff"));
275 }
276 }
277
278 #[test]
279 fn methods() {
280 let r = serve(ASSETS, &req("HEAD", "/login")).unwrap();
281 assert_eq!(r.status, 200);
282 assert!(r.body.is_empty());
283 let r = serve(ASSETS, &req("POST", "/login")).unwrap();
284 assert_eq!(r.status, 405);
285 assert_eq!(r.get_header("allow"), Some("GET, HEAD"));
286 }
287}