mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: delegate static asset policies to nginx
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+22
-16
@@ -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() {
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
//! 前端静态资源模块 —— SPA 路由、安全头与缓存策略。
|
||||
//! 前端静态资源模块 —— SPA 路由与构建产物服务。
|
||||
|
||||
pub mod assets;
|
||||
pub mod headers;
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user