拆分大文件
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -21,10 +24,13 @@ use shared_contracts::admin::{
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
api_response::json_success_body,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::{AdminRuntime, AppState},
|
||||
};
|
||||
|
||||
// 首版调试台只允许有限大小的请求体,避免把后台当作通用代理大包转发器。
|
||||
const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024;
|
||||
const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"host",
|
||||
@@ -33,6 +39,7 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"transfer-encoding",
|
||||
"expect",
|
||||
];
|
||||
// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。
|
||||
const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
||||
"runtime_setting",
|
||||
"runtime_snapshot",
|
||||
@@ -124,9 +131,9 @@ pub async fn admin_login(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<AdminLoginRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let runtime = state
|
||||
.admin_runtime()
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?;
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
|
||||
let expected_username = runtime.username().trim();
|
||||
let expected_password = runtime.password().trim();
|
||||
@@ -139,16 +146,18 @@ pub async fn admin_login(
|
||||
}
|
||||
|
||||
if submitted_username != expected_username || submitted_password != expected_password {
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误")
|
||||
);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let claims = runtime
|
||||
.build_claims(now)
|
||||
.map_err(|error| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error))?;
|
||||
let token = runtime
|
||||
.sign_token(&claims)
|
||||
.map_err(|error| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error))?;
|
||||
let claims = runtime.build_claims(now).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||||
})?;
|
||||
let token = runtime.sign_token(&claims).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -176,9 +185,9 @@ pub async fn admin_overview(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let runtime = state
|
||||
.admin_runtime()
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?;
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
|
||||
let overview = build_admin_overview(&state, runtime).await?;
|
||||
Ok(json_success_body(Some(&request_context), overview))
|
||||
@@ -199,9 +208,10 @@ pub async fn require_admin_auth(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let runtime = state
|
||||
.admin_runtime()
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?;
|
||||
// 后台鉴权必须同时满足:令牌验签通过、主体匹配当前管理员、roles 含 admin。
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let claims = runtime
|
||||
.verify_token(&bearer_token)
|
||||
@@ -213,7 +223,9 @@ pub async fn require_admin_auth(
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedAdmin::new(build_admin_session_payload(admin_session)));
|
||||
.insert(AuthenticatedAdmin::new(build_admin_session_payload(
|
||||
admin_session,
|
||||
)));
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -252,10 +264,16 @@ async fn build_admin_overview(
|
||||
}
|
||||
|
||||
async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPayload {
|
||||
// 概览直接读取 SpacetimeDB HTTP API,保证后台看到的是真实数据库元信息而不是本地缓存。
|
||||
let client = Client::new();
|
||||
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
|
||||
let database = state.config.spacetime_database.trim();
|
||||
let token = state.config.spacetime_token.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
let token = state
|
||||
.config
|
||||
.spacetime_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let mut fetch_errors = Vec::new();
|
||||
|
||||
let database_info = fetch_spacetime_json::<SpacetimeDatabaseInfoResponse>(
|
||||
@@ -321,9 +339,15 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
schema_table_names.sort();
|
||||
|
||||
AdminDatabaseOverviewPayload {
|
||||
database_identity: database_info.as_ref().and_then(|value| value.database_identity.clone()),
|
||||
owner_identity: database_info.as_ref().and_then(|value| value.owner_identity.clone()),
|
||||
host_type: database_info.as_ref().and_then(|value| value.host_type.clone()),
|
||||
database_identity: database_info
|
||||
.as_ref()
|
||||
.and_then(|value| value.database_identity.clone()),
|
||||
owner_identity: database_info
|
||||
.as_ref()
|
||||
.and_then(|value| value.owner_identity.clone()),
|
||||
host_type: database_info
|
||||
.as_ref()
|
||||
.and_then(|value| value.host_type.clone()),
|
||||
schema_table_names,
|
||||
table_stats,
|
||||
fetch_errors,
|
||||
@@ -426,15 +450,12 @@ async fn execute_admin_debug_http(
|
||||
state: &AppState,
|
||||
payload: AdminDebugHttpRequest,
|
||||
) -> Result<AdminDebugHttpResponse, AppError> {
|
||||
// 调试请求始终回打当前 api-server,同源受控,不允许作为外部代理使用。
|
||||
let method = Method::from_bytes(payload.method.trim().as_bytes()).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("HTTP 方法不合法")
|
||||
})?;
|
||||
let path = normalize_debug_path(&payload.path)?;
|
||||
let base_url = format!(
|
||||
"http://{}:{}",
|
||||
state.config.bind_host.trim(),
|
||||
state.config.bind_port
|
||||
);
|
||||
let base_url = build_debug_base_url(&state.config.bind_host, state.config.bind_port);
|
||||
let target_url = format!("{base_url}{path}");
|
||||
let body_text = payload.body.unwrap_or_default();
|
||||
if body_text.len() > MAX_DEBUG_BODY_BYTES {
|
||||
@@ -451,7 +472,10 @@ async fn execute_admin_debug_http(
|
||||
|
||||
for header in payload.headers.unwrap_or_default() {
|
||||
let header_name = header.name.trim().to_ascii_lowercase();
|
||||
if BLOCKED_DEBUG_HEADERS.iter().any(|blocked| *blocked == header_name) {
|
||||
if BLOCKED_DEBUG_HEADERS
|
||||
.iter()
|
||||
.any(|blocked| *blocked == header_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| {
|
||||
@@ -464,7 +488,8 @@ async fn execute_admin_debug_http(
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!("调试请求失败:{error}"))
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("调试请求失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let headers = response
|
||||
@@ -476,7 +501,8 @@ async fn execute_admin_debug_http(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let response_body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!("调试响应读取失败:{error}"))
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("调试响应读取失败:{error}"))
|
||||
})?;
|
||||
let body_preview = build_body_preview(&response_body);
|
||||
let body_json = serde_json::from_slice::<Value>(&response_body).ok();
|
||||
@@ -490,19 +516,56 @@ async fn execute_admin_debug_http(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_debug_base_url(bind_host: &str, bind_port: u16) -> String {
|
||||
let debug_host = resolve_debug_host(bind_host);
|
||||
let authority_host = format_http_authority_host(&debug_host);
|
||||
format!("http://{authority_host}:{bind_port}")
|
||||
}
|
||||
|
||||
fn resolve_debug_host(bind_host: &str) -> String {
|
||||
let trimmed = bind_host.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ipv4Addr::LOCALHOST.to_string();
|
||||
}
|
||||
|
||||
match trimmed.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(ip)) if ip.is_unspecified() => Ipv4Addr::LOCALHOST.to_string(),
|
||||
Ok(IpAddr::V6(ip)) if ip.is_unspecified() => Ipv6Addr::LOCALHOST.to_string(),
|
||||
Ok(ip) => ip.to_string(),
|
||||
Err(_) => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_http_authority_host(host: &str) -> String {
|
||||
if host.starts_with('[') && host.ends_with(']') {
|
||||
return host.to_string();
|
||||
}
|
||||
if host.parse::<Ipv6Addr>().is_ok() {
|
||||
return format!("[{host}]");
|
||||
}
|
||||
host.to_string()
|
||||
}
|
||||
|
||||
fn normalize_debug_path(path: &str) -> Result<String, AppError> {
|
||||
// 只允许 `/xxx` 形式的同源相对路径,明确拒绝绝对 URL 与后台登录接口。
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径不能为空"));
|
||||
}
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径")
|
||||
);
|
||||
}
|
||||
if !trimmed.starts_with('/') {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头")
|
||||
);
|
||||
}
|
||||
if trimmed == "/admin/api/login" {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口")
|
||||
);
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
@@ -540,6 +603,7 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
}
|
||||
}
|
||||
|
||||
// 首版后台页面内嵌在 api-server,避免新增独立前端工程与静态资源发布链。
|
||||
static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -1051,23 +1115,46 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_body_preview, normalize_debug_path, trim_preview};
|
||||
use axum::http::StatusCode;
|
||||
use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_absolute_url() {
|
||||
let error = normalize_debug_path("https://example.com/api").expect_err("absolute url should fail");
|
||||
let error =
|
||||
normalize_debug_path("https://example.com/api").expect_err("absolute url should fail");
|
||||
|
||||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_admin_login_route() {
|
||||
let error = normalize_debug_path("/admin/api/login").expect_err("admin login route should fail");
|
||||
let error =
|
||||
normalize_debug_path("/admin/api/login").expect_err("admin login route should fail");
|
||||
|
||||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_accepts_healthz() {
|
||||
let path = normalize_debug_path("/healthz").expect("healthz path should pass validation");
|
||||
|
||||
assert_eq!(path, "/healthz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_debug_base_url_rewrites_wildcard_ipv4_to_loopback() {
|
||||
let url = build_debug_base_url("0.0.0.0", 3200);
|
||||
|
||||
assert_eq!(url, "http://127.0.0.1:3200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_debug_base_url_wraps_ipv6_host() {
|
||||
let url = build_debug_base_url("::1", 3200);
|
||||
|
||||
assert_eq!(url, "http://[::1]:3200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_preview_limits_length() {
|
||||
let text = "a".repeat(5000);
|
||||
|
||||
Reference in New Issue
Block a user