This commit is contained in:
@@ -39,43 +39,6 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"transfer-encoding",
|
||||
"expect",
|
||||
];
|
||||
// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。
|
||||
const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
||||
"runtime_setting",
|
||||
"runtime_snapshot",
|
||||
"user_browse_history",
|
||||
"profile_dashboard_state",
|
||||
"profile_wallet_ledger",
|
||||
"profile_played_world",
|
||||
"profile_save_archive",
|
||||
"story_session",
|
||||
"story_event",
|
||||
"battle_state",
|
||||
"inventory_slot",
|
||||
"quest_record",
|
||||
"quest_log",
|
||||
"treasure_record",
|
||||
"npc_state",
|
||||
"custom_world_profile",
|
||||
"custom_world_gallery_entry",
|
||||
"custom_world_agent_session",
|
||||
"custom_world_agent_message",
|
||||
"custom_world_agent_operation",
|
||||
"custom_world_draft_card",
|
||||
"big_fish_creation_session",
|
||||
"big_fish_agent_message",
|
||||
"big_fish_asset_slot",
|
||||
"puzzle_work_profile",
|
||||
"puzzle_agent_session",
|
||||
"puzzle_agent_message",
|
||||
"puzzle_runtime_run",
|
||||
"ai_task",
|
||||
"ai_task_stage",
|
||||
"ai_text_chunk",
|
||||
"ai_result_reference",
|
||||
"asset_object",
|
||||
"asset_entity_binding",
|
||||
];
|
||||
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
|
||||
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
|
||||
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
|
||||
@@ -283,7 +246,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let mut schema_table_names = schema
|
||||
let schema_table_names = schema
|
||||
.as_ref()
|
||||
.and_then(|value| value.tables.as_ref())
|
||||
.map(|tables| {
|
||||
@@ -300,31 +263,33 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut table_stats = Vec::new();
|
||||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||||
for table_name in &schema_table_names {
|
||||
if !is_safe_spacetime_table_name(table_name) {
|
||||
table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: table_name.clone(),
|
||||
row_count: None,
|
||||
error_message: Some("表名不适合 SQL 统计".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}");
|
||||
match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await {
|
||||
Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: (*table_name).to_string(),
|
||||
table_name: table_name.clone(),
|
||||
row_count: Some(row_count),
|
||||
error_message: None,
|
||||
}),
|
||||
Err(error) => {
|
||||
table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: (*table_name).to_string(),
|
||||
table_name: table_name.clone(),
|
||||
row_count: None,
|
||||
error_message: Some(error),
|
||||
error_message: Some(normalize_table_count_error(&error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||||
if !schema_table_names.iter().any(|name| name == table_name) {
|
||||
schema_table_names.push((*table_name).to_string());
|
||||
}
|
||||
}
|
||||
schema_table_names.sort();
|
||||
|
||||
AdminDatabaseOverviewPayload {
|
||||
database_identity: database_info
|
||||
.as_ref()
|
||||
@@ -345,6 +310,27 @@ fn build_spacetime_schema_url(server_root: &str, database: &str) -> String {
|
||||
format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}")
|
||||
}
|
||||
|
||||
// 表名来自 schema,但进入 SQL 前仍做最小标识符校验,避免未来 schema 来源变化时扩大风险面。
|
||||
fn is_safe_spacetime_table_name(table_name: &str) -> bool {
|
||||
let mut chars = table_name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
// private 表在 SpacetimeDB SQL 下会表现为不可见,后台只展示可理解状态,不暴露整段 HTTP 噪音。
|
||||
fn normalize_table_count_error(error: &str) -> String {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
if normalized.contains("marked private") || normalized.contains("no such table") {
|
||||
return "不可统计(private 或当前身份不可见)".to_string();
|
||||
}
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
async fn fetch_spacetime_json<T>(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
@@ -662,7 +648,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path,
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url,
|
||||
is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error,
|
||||
parse_spacetime_sql_count_response, trim_preview,
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
@@ -722,6 +709,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_spacetime_table_name_accepts_schema_identifiers() {
|
||||
assert!(is_safe_spacetime_table_name("runtime_setting"));
|
||||
assert!(is_safe_spacetime_table_name("_private_table"));
|
||||
assert!(is_safe_spacetime_table_name("AiTaskStage2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_spacetime_table_name_rejects_sql_fragments() {
|
||||
assert!(!is_safe_spacetime_table_name(""));
|
||||
assert!(!is_safe_spacetime_table_name("bad-name"));
|
||||
assert!(!is_safe_spacetime_table_name("1bad"));
|
||||
assert!(!is_safe_spacetime_table_name("runtime_setting;DROP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_table_count_error_hides_private_table_http_noise() {
|
||||
let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private.";
|
||||
|
||||
assert_eq!(
|
||||
normalize_table_count_error(error),
|
||||
"不可统计(private 或当前身份不可见)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_table_count_error_keeps_other_errors() {
|
||||
let error = "SQL 请求失败:connection refused";
|
||||
|
||||
assert_eq!(normalize_table_count_error(error), error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spacetime_sql_count_response_accepts_statement_array_rows() {
|
||||
let payload = json!([
|
||||
|
||||
Reference in New Issue
Block a user