Skip to main content

camel_component_http/
static_dispatch.rs

1use 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
14/// Check that `path` starts with `prefix` at a segment boundary.
15/// Mount at `/asset` must NOT match `/assets/file.txt`.
16fn prefix_matches_segment(path: &str, prefix: &str) -> bool {
17    if prefix == "/" {
18        return true; // root matches everything
19    }
20    if !path.starts_with(prefix) {
21        return false;
22    }
23    path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/'
24}
25
26/// Attempt to serve a request from static mounts, SPA fallback, or error pages.
27///
28/// Mounts are tried in descending order by `mount_path` length (longest prefix
29/// wins). For each matching mount, ServeDir is tried first; if that fails and
30/// the mount is an SPA mount, the SPA fallback is attempted. Error pages are
31/// scoped to the best-matching mount — not cross-contaminated from other mounts.
32pub(crate) async fn dispatch_static(
33    state: &AppState,
34    req: Request,
35    path: &str,
36) -> axum::response::Response {
37    // Reject path traversal attempts early (string-only, no FS call)
38    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    // Extract request parts once so we can rebuild for each mount attempt
45    let (parts, _body) = req.into_parts();
46
47    // Collect mount info while lock is held
48    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    }; // lock released
64
65    // Sort mounts by mount_path length DESC (longest prefix wins)
66    let mut indexed: Vec<_> = mounts.into_iter().collect();
67    indexed.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
68
69    // Track best-matching mount for error page scoping (Fix 3)
70    let mut best_match: Option<(String, HashMap<u16, PathBuf>)> = None;
71
72    // Try each mount — longest prefix first
73    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        // Record best match (only on first/longest match)
79        if best_match.is_none() {
80            best_match = Some((mp.clone(), error_pages.clone()));
81        }
82
83        // Strip the mount_path prefix and rebuild the request URI for ServeDir
84        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        // ServeDir returned non-2xx
98        // For SPA mounts, try SPA fallback BEFORE error pages (SPA wins).
99        // SPA fallback means all unknown routes are handled by the SPA's
100        // index.html. Explicit error pages are only used when SPA fallback
101        // is disabled (MountMode::Static), when the request is not
102        // SPA-qualified (e.g., has a file extension), or when the SPA
103        // fallback itself fails.
104        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        // Try error page for this mount (for Static mode or non-qualified SPA requests)
112        if let Some(error_resp) = try_serve_error_page(error_pages, resp.status().as_u16()).await {
113            return error_resp;
114        }
115    }
116
117    // No mount matched — use best-match error pages only (Fix 3)
118    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    // Optional: try root-mount ("/") error pages as last resort
126    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
143/// Rebuild a request from captured parts (Request is not Clone).
144/// Uses empty body since static serving is always GET/HEAD.
145fn 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") // allow-unwrap
157}
158
159/// Rebuild a request from captured parts, but with a rewritten URI path.
160/// Used when stripping mount_path prefix before delegating to ServeDir.
161fn 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") // allow-unwrap
174}
175
176/// Serve a static file by delegating to `ServeDir`, adding `Cache-Control` on success.
177async 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
198/// Check whether a request qualifies for SPA fallback.
199///
200/// SPA fallback only triggers when ALL conditions are met:
201/// - Request method is GET or HEAD
202/// - `Accept` header includes `text/html` or `*/*`
203/// - Request path has no file extension
204fn 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
222/// Serve the SPA index.html by rewriting the request URI to "/".
223/// ServeDir serves from the root of the configured directory — the mount_path
224/// is only for URL routing, not file paths.
225async 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"); // allow-unwrap
239    serve_via_serve_dir(serve_dir, rewrite, cache_control).await
240}
241
242/// Attempt to serve a custom error page for the given status code.
243/// Returns `Some(response)` if an error page is configured and served, `None` otherwise.
244///
245/// The response status is overridden to the original error status so that
246/// serving a 404.html returns 404 (not 200).
247async 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        // Build a ServeDir for the error page's parent directory
253        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"); // allow-unwrap
261            let mut resp = serve_via_serve_dir(error_serve_dir, error_req, "no-cache").await;
262            // Override status: ServeDir returns 200 for the file, but we want the original error code.
263            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        // Mount at /asset must NOT match /assets
294        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}