camel_component_http/
static_dispatch.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use axum::body::Body as AxumBody;
5use axum::extract::Request;
6use axum::http::{Response, StatusCode};
7use axum::response::IntoResponse;
8use tower::ServiceExt as TowerServiceExt;
9use tower_http::services::ServeDir;
10
11use crate::AppState;
12use crate::registry::MountMode;
13
14fn prefix_matches_segment(path: &str, prefix: &str) -> bool {
17 if prefix == "/" {
18 return true; }
20 if !path.starts_with(prefix) {
21 return false;
22 }
23 path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/'
24}
25
26pub(crate) async fn dispatch_static(
33 state: &AppState,
34 req: Request,
35 path: &str,
36) -> axum::response::Response {
37 let relative = path.trim_start_matches('/');
39 let has_traversal = relative.split('/').any(|seg| seg == "..");
40 if has_traversal {
41 return (StatusCode::NOT_FOUND, "Not Found").into_response();
42 }
43
44 let (parts, _body) = req.into_parts();
46
47 let mounts: Vec<_> = {
49 let inner = state.registry.inner.read().await;
50 inner
51 .mounts
52 .iter()
53 .map(|m| {
54 (
55 m.mount_path.clone(),
56 m.mode,
57 m.serve_dir.clone(),
58 m.cache_control.clone(),
59 m.error_pages.clone(),
60 )
61 })
62 .collect()
63 }; let mut indexed: Vec<_> = mounts.into_iter().collect();
67 indexed.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
68
69 let mut best_match: Option<(String, HashMap<u16, PathBuf>)> = None;
71
72 for (mp, mode, serve_dir, cache_control, error_pages) in &indexed {
74 if !prefix_matches_segment(path, mp.as_str()) {
75 continue;
76 }
77
78 if best_match.is_none() {
80 best_match = Some((mp.clone(), error_pages.clone()));
81 }
82
83 let stripped_path = if mp == "/" {
85 relative.to_string()
86 } else {
87 let remainder = path.strip_prefix(mp.as_str()).unwrap_or("");
88 remainder.trim_start_matches('/').to_string()
89 };
90
91 let req = rebuild_request_with_path(&parts, &stripped_path);
92 let resp = serve_via_serve_dir(serve_dir.clone(), req, cache_control).await;
93 if resp.status().is_success() {
94 return resp;
95 }
96
97 if *mode == MountMode::Spa && resp.status() == StatusCode::NOT_FOUND {
105 let spa_req = rebuild_request(&parts);
106 if is_spa_qualified(&spa_req) {
107 return serve_spa_index(serve_dir.clone(), spa_req, cache_control).await;
108 }
109 }
110
111 if let Some(error_resp) = try_serve_error_page(error_pages, resp.status().as_u16()).await {
113 return error_resp;
114 }
115 }
116
117 if let Some((_, error_pages)) = &best_match
119 && let Some(error_resp) =
120 try_serve_error_page(error_pages, StatusCode::NOT_FOUND.as_u16()).await
121 {
122 return error_resp;
123 }
124
125 let root_error_pages: Option<HashMap<u16, PathBuf>> = {
127 let inner = state.registry.inner.read().await;
128 inner
129 .mounts
130 .iter()
131 .find(|m| m.mount_path == "/")
132 .map(|m| m.error_pages.clone())
133 };
134 if let Some(pages) = root_error_pages
135 && let Some(error_resp) = try_serve_error_page(&pages, StatusCode::NOT_FOUND.as_u16()).await
136 {
137 return error_resp;
138 }
139
140 (StatusCode::NOT_FOUND, "Not Found").into_response()
141}
142
143fn rebuild_request(parts: &http::request::Parts) -> Request {
146 let mut builder = http::Request::builder()
147 .method(parts.method.clone())
148 .uri(parts.uri.clone())
149 .version(parts.version);
150 for (k, v) in &parts.headers {
151 builder = builder.header(k, v);
152 }
153 builder
154 .extension(parts.extensions.clone())
155 .body(AxumBody::empty())
156 .expect("valid request rebuild") }
158
159fn rebuild_request_with_path(parts: &http::request::Parts, path: &str) -> Request {
162 let uri = format!("/{path}");
163 let mut builder = http::Request::builder()
164 .method(parts.method.clone())
165 .uri(&uri)
166 .version(parts.version);
167 for (k, v) in &parts.headers {
168 builder = builder.header(k, v);
169 }
170 builder
171 .extension(parts.extensions.clone())
172 .body(AxumBody::empty())
173 .expect("valid request rebuild") }
175
176async fn serve_via_serve_dir(
178 serve_dir: ServeDir,
179 req: Request,
180 cache_control: &str,
181) -> axum::response::Response {
182 match serve_dir.oneshot(req).await {
183 Ok(res) => {
184 let (mut parts, body) = res.into_parts();
185 if parts.status.is_success() {
186 parts.headers.insert(
187 http::header::CACHE_CONTROL,
188 http::HeaderValue::from_str(cache_control)
189 .unwrap_or_else(|_| http::HeaderValue::from_static("public, max-age=0")),
190 );
191 }
192 Response::from_parts(parts, AxumBody::new(body))
193 }
194 Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
195 }
196}
197
198fn is_spa_qualified(req: &Request) -> bool {
205 let method = req.method();
206 if method != http::Method::GET && method != http::Method::HEAD {
207 return false;
208 }
209 let accept = req
210 .headers()
211 .get(http::header::ACCEPT)
212 .and_then(|v| v.to_str().ok())
213 .unwrap_or("");
214 if !accept.contains("text/html") && !accept.contains("*/*") {
215 return false;
216 }
217 let path = req.uri().path();
218 let has_extension = std::path::Path::new(path).extension().is_some();
219 !has_extension
220}
221
222async fn serve_spa_index(
226 serve_dir: ServeDir,
227 req: Request,
228 cache_control: &str,
229) -> axum::response::Response {
230 let method = req.method().clone();
231 let headers = req.headers().clone();
232 let mut builder = axum::http::Request::builder()
233 .method(method)
234 .uri(axum::http::Uri::from_static("/"));
235 for (k, v) in headers.iter() {
236 builder = builder.header(k, v);
237 }
238 let rewrite = builder.body(AxumBody::empty()).expect("valid request"); serve_via_serve_dir(serve_dir, rewrite, cache_control).await
240}
241
242async fn try_serve_error_page(
248 error_pages: &HashMap<u16, PathBuf>,
249 status: u16,
250) -> Option<axum::response::Response> {
251 if let Some(error_path) = error_pages.get(&status) {
252 if let Some(parent) = error_path.parent() {
254 let file_name = error_path.file_name()?.to_str()?;
255 let error_serve_dir = ServeDir::new(parent);
256 let error_req = axum::http::Request::builder()
257 .method(http::Method::GET)
258 .uri(format!("/{file_name}"))
259 .body(AxumBody::empty())
260 .expect("valid request"); let mut resp = serve_via_serve_dir(error_serve_dir, error_req, "no-cache").await;
262 if resp.status().is_success()
264 && let Ok(code) = StatusCode::from_u16(status)
265 {
266 *resp.status_mut() = code;
267 }
268 return Some(resp);
269 }
270 }
271 None
272}
273
274#[cfg(test)]
275mod tests {
276 use super::prefix_matches_segment;
277
278 #[test]
279 fn root_prefix_matches_everything() {
280 assert!(prefix_matches_segment("/", "/"));
281 assert!(prefix_matches_segment("/foo", "/"));
282 assert!(prefix_matches_segment("/foo/bar", "/"));
283 }
284
285 #[test]
286 fn exact_prefix_match() {
287 assert!(prefix_matches_segment("/asset", "/asset"));
288 assert!(prefix_matches_segment("/asset/file.txt", "/asset"));
289 }
290
291 #[test]
292 fn segment_boundary_rejects_partial_match() {
293 assert!(!prefix_matches_segment("/assets", "/asset"));
295 assert!(!prefix_matches_segment("/assets/file.txt", "/asset"));
296 assert!(!prefix_matches_segment("/assetx", "/asset"));
297 }
298
299 #[test]
300 fn non_matching_prefix() {
301 assert!(!prefix_matches_segment("/other", "/asset"));
302 assert!(!prefix_matches_segment("/other/file.txt", "/asset"));
303 }
304
305 #[test]
306 fn nested_prefix() {
307 assert!(prefix_matches_segment(
308 "/assets/sub/file.txt",
309 "/assets/sub"
310 ));
311 assert!(!prefix_matches_segment(
312 "/assets/other/file.txt",
313 "/assets/sub"
314 ));
315 }
316}