fix: delegate static asset policies to nginx

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 14:25:06 +08:00
co-authored by Codex
parent a12c61b4f4
commit cb0a6de389
4 changed files with 37 additions and 100 deletions
+22 -16
View File
@@ -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<PathBuf>,
@@ -33,7 +31,7 @@ pub fn spa_router(dist_dir: impl AsRef<Path>) -> Router {
}
async fn serve_index(State(state): State<AssetState>) -> 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<AssetState>, uri: Uri) -> Response {
@@ -49,7 +47,7 @@ async fn serve_spa_fallback(State(state): State<AssetState>, 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<PathBuf> {
let mut sanitized = PathBuf::new();
for component in Path::new(path).components() {
-55
View File
@@ -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")
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
//! 前端静态资源模块 —— SPA 路由、安全头与缓存策略。
//! 前端静态资源模块 —— SPA 路由与构建产物服务。
pub mod assets;
pub mod headers;
+14 -27
View File
@@ -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());