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    // Path traversal is contained by ServeDir (tower-http 0.7.0), which
38    // canonicalizes and rejects `..` / percent-encoded sequences. The static
39    // root directory and its symlink targets must be trusted by the operator.
40    // (R4-L1: removed dead-weight string guard that missed %2e%2e.)
41    let relative = path.trim_start_matches('/');
42
43    // Extract request parts once so we can rebuild for each mount attempt
44    let (parts, _body) = req.into_parts();
45
46    // Collect mount info while lock is held
47    let mounts: Vec<_> = {
48        let inner = state.registry.inner.read().await;
49        inner
50            .mounts
51            .iter()
52            .map(|m| {
53                (
54                    m.mount_path.clone(),
55                    m.mode,
56                    m.serve_dir.clone(),
57                    m.cache_control.clone(),
58                    m.error_pages.clone(),
59                )
60            })
61            .collect()
62    }; // lock released
63
64    // Sort mounts by mount_path length DESC (longest prefix wins)
65    let mut indexed: Vec<_> = mounts.into_iter().collect();
66    indexed.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
67
68    // Track best-matching mount for error page scoping (Fix 3)
69    let mut best_match: Option<(String, HashMap<u16, PathBuf>)> = None;
70
71    // Try each mount — longest prefix first
72    for (mp, mode, serve_dir, cache_control, error_pages) in &indexed {
73        if !prefix_matches_segment(path, mp.as_str()) {
74            continue;
75        }
76
77        // Record best match (only on first/longest match)
78        if best_match.is_none() {
79            best_match = Some((mp.clone(), error_pages.clone()));
80        }
81
82        // Strip the mount_path prefix and rebuild the request URI for ServeDir
83        let stripped_path = if mp == "/" {
84            relative.to_string()
85        } else {
86            let remainder = path.strip_prefix(mp.as_str()).unwrap_or("");
87            remainder.trim_start_matches('/').to_string()
88        };
89
90        let req = rebuild_request_with_path(&parts, &stripped_path);
91        let resp = serve_via_serve_dir(serve_dir.clone(), req, cache_control).await;
92        // Forward ServeDir's success AND its legitimate 304 Not Modified for
93        // conditional GETs (If-None-Match / If-Modified-Since). Without this
94        // gate, a matching conditional request falls through to the generic
95        // 404 at the bottom of dispatch_static. Per RFC 7232 §2.1/§4.1.
96        if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
97            return resp;
98        }
99
100        // ServeDir returned non-2xx
101        // For SPA mounts, try SPA fallback BEFORE error pages (SPA wins).
102        // SPA fallback means all unknown routes are handled by the SPA's
103        // index.html. Explicit error pages are only used when SPA fallback
104        // is disabled (MountMode::Static), when the request is not
105        // SPA-qualified (e.g., has a file extension), or when the SPA
106        // fallback itself fails.
107        if *mode == MountMode::Spa && resp.status() == StatusCode::NOT_FOUND {
108            let spa_req = rebuild_request(&parts);
109            if is_spa_qualified(&spa_req) {
110                return serve_spa_index(serve_dir.clone(), spa_req, cache_control).await;
111            }
112        }
113
114        // Try error page for this mount (for Static mode or non-qualified SPA requests)
115        if let Some(error_resp) = try_serve_error_page(error_pages, resp.status().as_u16()).await {
116            return error_resp;
117        }
118    }
119
120    // No mount matched — use best-match error pages only (Fix 3)
121    if let Some((_, error_pages)) = &best_match
122        && let Some(error_resp) =
123            try_serve_error_page(error_pages, StatusCode::NOT_FOUND.as_u16()).await
124    {
125        return error_resp;
126    }
127
128    // Optional: try root-mount ("/") error pages as last resort
129    let root_error_pages: Option<HashMap<u16, PathBuf>> = {
130        let inner = state.registry.inner.read().await;
131        inner
132            .mounts
133            .iter()
134            .find(|m| m.mount_path == "/")
135            .map(|m| m.error_pages.clone())
136    };
137    if let Some(pages) = root_error_pages
138        && let Some(error_resp) = try_serve_error_page(&pages, StatusCode::NOT_FOUND.as_u16()).await
139    {
140        return error_resp;
141    }
142
143    (StatusCode::NOT_FOUND, "Not Found").into_response()
144}
145
146/// Rebuild a request from captured parts (Request is not Clone).
147/// Uses empty body since static serving is always GET/HEAD.
148fn rebuild_request(parts: &http::request::Parts) -> Request {
149    let mut builder = http::Request::builder()
150        .method(parts.method.clone())
151        .uri(parts.uri.clone())
152        .version(parts.version);
153    for (k, v) in &parts.headers {
154        builder = builder.header(k, v);
155    }
156    builder
157        .extension(parts.extensions.clone())
158        .body(AxumBody::empty())
159        .expect("valid request rebuild") // allow-unwrap
160}
161
162/// Rebuild a request from captured parts, but with a rewritten URI path.
163/// Used when stripping mount_path prefix before delegating to ServeDir.
164fn rebuild_request_with_path(parts: &http::request::Parts, path: &str) -> Request {
165    let uri = format!("/{path}");
166    let mut builder = http::Request::builder()
167        .method(parts.method.clone())
168        .uri(&uri)
169        .version(parts.version);
170    for (k, v) in &parts.headers {
171        builder = builder.header(k, v);
172    }
173    builder
174        .extension(parts.extensions.clone())
175        .body(AxumBody::empty())
176        .expect("valid request rebuild") // allow-unwrap
177}
178
179/// Serve a static file by delegating to `ServeDir`, adding `Cache-Control` on success.
180async fn serve_via_serve_dir(
181    serve_dir: ServeDir,
182    req: Request,
183    cache_control: &str,
184) -> axum::response::Response {
185    match serve_dir.oneshot(req).await {
186        Ok(res) => {
187            let (mut parts, body) = res.into_parts();
188            // Attach Cache-Control on 2xx success AND on 304 Not Modified.
189            // Per RFC 7232 §4.1, a 304 response SHOULD include Cache-Control
190            // (and the validators ETag/Last-Modified, which ServeDir already
191            // carries over from the original 200). Without this gate, a
192            // conditional GET that correctly returns 304 would be missing
193            // the caching directive.
194            if parts.status.is_success() || parts.status == StatusCode::NOT_MODIFIED {
195                parts.headers.insert(
196                    http::header::CACHE_CONTROL,
197                    http::HeaderValue::from_str(cache_control)
198                        .unwrap_or_else(|_| http::HeaderValue::from_static("public, max-age=0")),
199                );
200            }
201            Response::from_parts(parts, AxumBody::new(body))
202        }
203        Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
204    }
205}
206
207/// Check whether a request qualifies for SPA fallback.
208///
209/// SPA fallback only triggers when ALL conditions are met:
210/// - Request method is GET or HEAD
211/// - `Accept` header includes `text/html` or `*/*`
212/// - Request path has no file extension
213fn is_spa_qualified(req: &Request) -> bool {
214    let method = req.method();
215    if method != http::Method::GET && method != http::Method::HEAD {
216        return false;
217    }
218    let accept = req
219        .headers()
220        .get(http::header::ACCEPT)
221        .and_then(|v| v.to_str().ok())
222        .unwrap_or("");
223    if !accept.contains("text/html") && !accept.contains("*/*") {
224        return false;
225    }
226    let path = req.uri().path();
227    let has_extension = std::path::Path::new(path).extension().is_some();
228    !has_extension
229}
230
231/// Serve the SPA index.html by rewriting the request URI to "/".
232/// ServeDir serves from the root of the configured directory — the mount_path
233/// is only for URL routing, not file paths.
234async fn serve_spa_index(
235    serve_dir: ServeDir,
236    req: Request,
237    cache_control: &str,
238) -> axum::response::Response {
239    let method = req.method().clone();
240    let headers = req.headers().clone();
241    let mut builder = axum::http::Request::builder()
242        .method(method)
243        .uri(axum::http::Uri::from_static("/"));
244    for (k, v) in headers.iter() {
245        builder = builder.header(k, v);
246    }
247    let rewrite = builder.body(AxumBody::empty()).expect("valid request"); // allow-unwrap
248    serve_via_serve_dir(serve_dir, rewrite, cache_control).await
249}
250
251/// Attempt to serve a custom error page for the given status code.
252/// Returns `Some(response)` if an error page is configured and served, `None` otherwise.
253///
254/// The response status is overridden to the original error status so that
255/// serving a 404.html returns 404 (not 200).
256async fn try_serve_error_page(
257    error_pages: &HashMap<u16, PathBuf>,
258    status: u16,
259) -> Option<axum::response::Response> {
260    if let Some(error_path) = error_pages.get(&status) {
261        // Build a ServeDir for the error page's parent directory
262        if let Some(parent) = error_path.parent() {
263            let file_name = error_path.file_name()?.to_str()?;
264            let error_serve_dir = ServeDir::new(parent);
265            let error_req = axum::http::Request::builder()
266                .method(http::Method::GET)
267                .uri(format!("/{file_name}"))
268                .body(AxumBody::empty())
269                .expect("valid request"); // allow-unwrap
270            let mut resp = serve_via_serve_dir(error_serve_dir, error_req, "no-cache").await;
271            // Override status: ServeDir returns 200 for the file, but we want the original error code.
272            if resp.status().is_success()
273                && let Ok(code) = StatusCode::from_u16(status)
274            {
275                *resp.status_mut() = code;
276            }
277            return Some(resp);
278        }
279    }
280    None
281}
282
283#[cfg(test)]
284mod tests {
285    use super::prefix_matches_segment;
286
287    #[test]
288    fn root_prefix_matches_everything() {
289        assert!(prefix_matches_segment("/", "/"));
290        assert!(prefix_matches_segment("/foo", "/"));
291        assert!(prefix_matches_segment("/foo/bar", "/"));
292    }
293
294    #[test]
295    fn exact_prefix_match() {
296        assert!(prefix_matches_segment("/asset", "/asset"));
297        assert!(prefix_matches_segment("/asset/file.txt", "/asset"));
298    }
299
300    #[test]
301    fn segment_boundary_rejects_partial_match() {
302        // Mount at /asset must NOT match /assets
303        assert!(!prefix_matches_segment("/assets", "/asset"));
304        assert!(!prefix_matches_segment("/assets/file.txt", "/asset"));
305        assert!(!prefix_matches_segment("/assetx", "/asset"));
306    }
307
308    #[test]
309    fn non_matching_prefix() {
310        assert!(!prefix_matches_segment("/other", "/asset"));
311        assert!(!prefix_matches_segment("/other/file.txt", "/asset"));
312    }
313
314    #[test]
315    fn nested_prefix() {
316        assert!(prefix_matches_segment(
317            "/assets/sub/file.txt",
318            "/assets/sub"
319        ));
320        assert!(!prefix_matches_segment(
321            "/assets/other/file.txt",
322            "/assets/sub"
323        ));
324    }
325}