diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8ec1d0762..371ce9443 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1112,6 +1112,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1496,6 +1502,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2839,6 +2855,11 @@ dependencies = [ "http", "http-body", "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", @@ -2994,6 +3015,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e22e6111c..337999ab2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -37,7 +37,8 @@ tar = "0.4.44" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] } tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] } -tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout"] } +tower = { version = "0.5.3", features = ["util"] } +tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout", "fs"] } tracing = "0.1.44" tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } @@ -46,7 +47,6 @@ uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } [dev-dependencies] tempfile = "3.27.0" -tower = { version = "0.5.3", features = ["util"] } wiremock = "0.6.5" [patch.crates-io] diff --git a/backend/src/web/assets.rs b/backend/src/web/assets.rs index 0cba648a2..4e8ce7f4e 100644 --- a/backend/src/web/assets.rs +++ b/backend/src/web/assets.rs @@ -1,41 +1,32 @@ use std::{ - path::{Component, Path, PathBuf}, - sync::Arc, + convert::Infallible, + path::{Path, PathBuf}, }; use axum::{ body::Body, - extract::{Path as AxumPath, State}, - http::{header, HeaderValue, StatusCode, Uri}, + http::{Request, StatusCode}, response::{IntoResponse, Response}, - routing::get, Json, Router, }; use serde_json::json; - -#[derive(Clone)] -struct AssetState { - dist_dir: Arc, -} +use tower::service_fn; +use tower_http::services::ServeDir; pub fn spa_router(dist_dir: impl AsRef) -> Router { - let state = AssetState { - dist_dir: Arc::new(dist_dir.as_ref().to_path_buf()), - }; + let dist_dir = dist_dir.as_ref().to_path_buf(); + let index_file = dist_dir.join("index.html"); + let fallback = service_fn(move |request: Request| { + let index_file = index_file.clone(); + async move { Ok::<_, Infallible>(spa_fallback_response(request, index_file).await) } + }); - Router::new() - .route("/", get(serve_index)) - .route("/assets/{*path}", get(serve_asset)) - .fallback(serve_spa_fallback) - .with_state(state) + Router::new().fallback_service(ServeDir::new(dist_dir).fallback(fallback)) } -async fn serve_index(State(state): State) -> Response { - serve_file(&state.dist_dir.join("index.html")).await -} - -async fn serve_spa_fallback(State(state): State, uri: Uri) -> Response { - if is_api_path(uri.path()) { +async fn spa_fallback_response(request: Request, index_file: PathBuf) -> Response { + let path = request.uri().path(); + if is_api_path(path) { return ( StatusCode::NOT_FOUND, Json(json!({ @@ -47,61 +38,32 @@ async fn serve_spa_fallback(State(state): State, uri: Uri) -> Respon .into_response(); } - serve_file(&state.dist_dir.join("index.html")).await + if is_static_file_path(path) { + return StatusCode::NOT_FOUND.into_response(); + } + + serve_index_file(&index_file).await } fn is_api_path(path: &str) -> bool { path == "/api" || path.starts_with("/api/") || path == "/v1" || path.starts_with("/v1/") } -async fn serve_asset( - State(state): State, - AxumPath(path): AxumPath, -) -> Response { - let Some(relative_path) = safe_relative_path(&path) else { - return StatusCode::NOT_FOUND.into_response(); - }; - serve_file(&state.dist_dir.join("assets").join(relative_path)).await -} - -async fn serve_file(path: &Path) -> Response { +async fn serve_index_file(path: &Path) -> Response { let Ok(bytes) = tokio::fs::read(path).await else { return StatusCode::NOT_FOUND.into_response(); }; let mut response = Response::new(Body::from(bytes)); - let headers = response.headers_mut(); - headers.insert( - header::CONTENT_TYPE, - content_type_for_path(&path.to_string_lossy()), + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/html; charset=utf-8"), ); response } -fn content_type_for_path(path: &str) -> HeaderValue { - if path.ends_with(".html") || path == "/" { - HeaderValue::from_static("text/html; charset=utf-8") - } else if path.ends_with(".js") { - HeaderValue::from_static("text/javascript; charset=utf-8") - } else if path.ends_with(".css") { - HeaderValue::from_static("text/css; charset=utf-8") - } else if path.ends_with(".json") { - HeaderValue::from_static("application/json") - } else if path.ends_with(".svg") { - HeaderValue::from_static("image/svg+xml") - } else { - HeaderValue::from_static("application/octet-stream") - } -} - -fn safe_relative_path(path: &str) -> Option { - let mut sanitized = PathBuf::new(); - for component in Path::new(path).components() { - match component { - Component::Normal(segment) => sanitized.push(segment), - _ => return None, - } - } - - (!sanitized.as_os_str().is_empty()).then_some(sanitized) +fn is_static_file_path(path: &str) -> bool { + Path::new(path.trim_start_matches('/')) + .extension() + .is_some() } diff --git a/backend/tests/http/web_assets/mod.rs b/backend/tests/http/web_assets/mod.rs index 4e7a29407..3fb936aa0 100644 --- a/backend/tests/http/web_assets/mod.rs +++ b/backend/tests/http/web_assets/mod.rs @@ -22,6 +22,11 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes "
Codex Proxy Admin
", ) .expect("index should be written"); + std::fs::write( + dist.join("favicon.svg"), + "Codex Proxy", + ) + .expect("favicon should be written"); std::fs::write(dist.join("assets").join("app.js"), "window.__asset = true;") .expect("asset should be written"); let db = dir.path().join("assets-routes.sqlite"); @@ -29,6 +34,21 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes let (app, _pool) = router_with_dist_and_database(&dist, &database_url).await; let index = app.clone().oneshot(request("/")).await.expect("index"); + let route_fallback = app + .clone() + .oneshot(request("/dashboard")) + .await + .expect("route fallback"); + let favicon = app + .clone() + .oneshot(request("/favicon.svg")) + .await + .expect("favicon"); + let missing_static = app + .clone() + .oneshot(request("/missing.svg")) + .await + .expect("missing static response"); let asset = app .clone() .oneshot(request("/assets/app.js")) @@ -45,12 +65,23 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes .expect("health response"); assert_eq!(index.status(), StatusCode::OK); + assert_eq!(route_fallback.status(), StatusCode::OK); + assert_eq!(favicon.status(), StatusCode::OK); + assert_eq!(missing_static.status(), StatusCode::NOT_FOUND); assert_eq!(asset.status(), StatusCode::OK); assert_eq!(api.status(), StatusCode::UNAUTHORIZED); assert_eq!(health.status(), StatusCode::NO_CONTENT); + assert_eq!( + favicon.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/svg+xml")) + ); assert_no_static_policy_headers(&index); + assert_no_static_policy_headers(&route_fallback); + assert_no_static_policy_headers(&favicon); assert_no_static_policy_headers(&asset); assert_body_contains(index, "Codex Proxy Admin").await; + assert_body_contains(route_fallback, "Codex Proxy Admin").await; + assert_body_contains(favicon, "Codex Proxy").await; assert_body_contains(asset, "__asset").await; assert_eq!(response_json(api).await["code"], Value::from(40101)); }