diff --git a/backend/src/web/assets.rs b/backend/src/web/assets.rs index 8afb546c4..0cba648a2 100644 --- a/backend/src/web/assets.rs +++ b/backend/src/web/assets.rs @@ -6,15 +6,13 @@ use std::{ use axum::{ body::Body, extract::{Path as AxumPath, State}, - http::{header, StatusCode, Uri}, + http::{header, HeaderValue, StatusCode, Uri}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use serde_json::json; -use super::headers::{apply_static_headers, content_type_for_path}; - #[derive(Clone)] struct AssetState { dist_dir: Arc, @@ -33,7 +31,7 @@ pub fn spa_router(dist_dir: impl AsRef) -> Router { } async fn serve_index(State(state): State) -> Response { - serve_file(&state.dist_dir.join("index.html"), "/").await + serve_file(&state.dist_dir.join("index.html")).await } async fn serve_spa_fallback(State(state): State, uri: Uri) -> Response { @@ -49,7 +47,7 @@ async fn serve_spa_fallback(State(state): State, uri: Uri) -> Respon .into_response(); } - serve_file(&state.dist_dir.join("index.html"), "/").await + serve_file(&state.dist_dir.join("index.html")).await } fn is_api_path(path: &str) -> bool { @@ -63,31 +61,39 @@ async fn serve_asset( let Some(relative_path) = safe_relative_path(&path) else { return StatusCode::NOT_FOUND.into_response(); }; - let request_path = format!("/assets/{path}"); - - serve_file( - &state.dist_dir.join("assets").join(relative_path), - &request_path, - ) - .await + serve_file(&state.dist_dir.join("assets").join(relative_path)).await } -async fn serve_file(path: &Path, request_path: &str) -> Response { +async fn serve_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 path_for_content_type = path.to_string_lossy(); let headers = response.headers_mut(); headers.insert( header::CONTENT_TYPE, - content_type_for_path(&path_for_content_type), + content_type_for_path(&path.to_string_lossy()), ); - apply_static_headers(headers, request_path); 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() { diff --git a/backend/src/web/headers.rs b/backend/src/web/headers.rs deleted file mode 100644 index f3f8730a0..000000000 --- a/backend/src/web/headers.rs +++ /dev/null @@ -1,55 +0,0 @@ -use axum::http::{header, HeaderMap, HeaderValue}; - -const SPA_CACHE_CONTROL: &str = "no-cache"; -const ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; -const CONTENT_SECURITY_POLICY: &str = concat!( - "default-src 'self'; ", - "script-src 'self'; ", - "style-src 'self' 'unsafe-inline'; ", - "img-src 'self' data:; ", - "font-src 'self' data:; ", - "connect-src 'self'; ", - "base-uri 'self'; ", - "frame-ancestors 'none'" -); - -pub fn cache_control_for_path(path: &str) -> &'static str { - if path.starts_with("/assets/") { - ASSET_CACHE_CONTROL - } else { - SPA_CACHE_CONTROL - } -} - -pub fn apply_static_headers(headers: &mut HeaderMap, request_path: &str) { - headers.insert( - header::CACHE_CONTROL, - HeaderValue::from_static(cache_control_for_path(request_path)), - ); - headers.insert( - header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - ); - headers.insert("x-frame-options", HeaderValue::from_static("DENY")); - headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); - headers.insert( - header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static(CONTENT_SECURITY_POLICY), - ); -} - -pub 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") - } -} diff --git a/backend/src/web/mod.rs b/backend/src/web/mod.rs index c41d420b8..57bbc6d7a 100644 --- a/backend/src/web/mod.rs +++ b/backend/src/web/mod.rs @@ -1,4 +1,3 @@ -//! 前端静态资源模块 —— SPA 路由、安全头与缓存策略。 +//! 前端静态资源模块 —— SPA 路由与构建产物服务。 pub mod assets; -pub mod headers; diff --git a/backend/tests/http/web_assets/mod.rs b/backend/tests/http/web_assets/mod.rs index f7df26880..4e7a29407 100644 --- a/backend/tests/http/web_assets/mod.rs +++ b/backend/tests/http/web_assets/mod.rs @@ -12,18 +12,6 @@ use tower::ServiceExt; use crate::support::{config::test_config as base_test_config, http::response_json}; -#[test] -fn asset_headers_should_distinguish_spa_and_fingerprinted_assets() { - assert_eq!( - codex_proxy_rs::web::headers::cache_control_for_path("/"), - "no-cache" - ); - assert_eq!( - codex_proxy_rs::web::headers::cache_control_for_path("/assets/app.abc123.js"), - "public, max-age=31536000, immutable" - ); -} - #[tokio::test] async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes() { let dir = tempfile::tempdir().expect("temp dir"); @@ -60,21 +48,8 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes assert_eq!(asset.status(), StatusCode::OK); assert_eq!(api.status(), StatusCode::UNAUTHORIZED); assert_eq!(health.status(), StatusCode::NO_CONTENT); - let csp = index - .headers() - .get(header::CONTENT_SECURITY_POLICY) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - assert!( - csp.contains("script-src 'self';") && csp.contains("style-src 'self' 'unsafe-inline';"), - "unexpected content security policy: {csp}" - ); - assert_eq!( - asset.headers().get(header::CACHE_CONTROL), - Some(&header::HeaderValue::from_static( - "public, max-age=31536000, immutable" - )) - ); + assert_no_static_policy_headers(&index); + assert_no_static_policy_headers(&asset); assert_body_contains(index, "Codex Proxy Admin").await; assert_body_contains(asset, "__asset").await; assert_eq!(response_json(api).await["code"], Value::from(40101)); @@ -153,6 +128,18 @@ async fn assert_unknown_api_route(response: axum::response::Response) { assert_eq!(body["message"], Value::from("API route not found")); } +fn assert_no_static_policy_headers(response: &axum::response::Response) { + assert!(!response.headers().contains_key(header::CACHE_CONTROL)); + assert!(!response + .headers() + .contains_key(header::CONTENT_SECURITY_POLICY)); + assert!(!response + .headers() + .contains_key(header::X_CONTENT_TYPE_OPTIONS)); + assert!(!response.headers().contains_key("x-frame-options")); + assert!(!response.headers().contains_key("referrer-policy")); +} + async fn router_with_dist_and_database(dist: &Path, database_url: &str) -> (Router, SqlitePool) { let pool = connect_sqlite(database_url).await.expect("sqlite pool"); let config = test_config(database_url.to_string());