Fix admin import export version checks

This commit is contained in:
fawney19
2026-05-06 16:06:45 +08:00
parent bfda9b3e78
commit 33ed7c0737
5 changed files with 95 additions and 21 deletions

View File

@@ -10,6 +10,7 @@ use aether_admin::system::{
serialize_admin_system_users_export_wallet, AdminSystemConfigDocument, AdminSystemConfigEntry, serialize_admin_system_users_export_wallet, AdminSystemConfigDocument, AdminSystemConfigEntry,
AdminSystemConfigGlobalModel, AdminSystemConfigLdap, AdminSystemConfigOAuthProvider, AdminSystemConfigGlobalModel, AdminSystemConfigLdap, AdminSystemConfigOAuthProvider,
AdminSystemConfigProxyNode, ADMIN_SYSTEM_CONFIG_EXPORT_VERSION, AdminSystemConfigProxyNode, ADMIN_SYSTEM_CONFIG_EXPORT_VERSION,
ADMIN_SYSTEM_USERS_EXPORT_VERSION,
}; };
use aether_data_contracts::repository::global_models::AdminGlobalModelListQuery; use aether_data_contracts::repository::global_models::AdminGlobalModelListQuery;
use chrono::Utc; use chrono::Utc;
@@ -165,8 +166,6 @@ impl<'a> AdminAppState<'a> {
pub(crate) async fn build_admin_system_users_export_payload( pub(crate) async fn build_admin_system_users_export_payload(
&self, &self,
) -> Result<serde_json::Value, GatewayError> { ) -> Result<serde_json::Value, GatewayError> {
const ADMIN_SYSTEM_USERS_EXPORT_VERSION: &str = "1.3";
let users = self.list_non_admin_export_users().await?; let users = self.list_non_admin_export_users().await?;
let user_ids = users.iter().map(|user| user.id.clone()).collect::<Vec<_>>(); let user_ids = users.iter().map(|user| user.id.clone()).collect::<Vec<_>>();
let user_wallets = self.list_wallet_snapshots_by_user_ids(&user_ids).await?; let user_wallets = self.list_wallet_snapshots_by_user_ids(&user_ids).await?;

View File

@@ -28,7 +28,7 @@ use aether_admin::system::{
AdminSystemConfigProviderKey as ImportedProviderKey, AdminSystemConfigProviderKey as ImportedProviderKey,
AdminSystemConfigProviderModel as ImportedProviderModel, AdminSystemConfigProviderModel as ImportedProviderModel,
AdminSystemConfigProxyNode as ImportedProxyNode, AdminSystemConfigProxyNode as ImportedProxyNode,
ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS, ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS, ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS,
}; };
use aether_data::repository::auth_modules::StoredLdapModuleConfig; use aether_data::repository::auth_modules::StoredLdapModuleConfig;
use aether_data::repository::oauth_providers::{ use aether_data::repository::oauth_providers::{
@@ -46,7 +46,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid; use uuid::Uuid;
const ADMIN_SYSTEM_IMPORT_MAX_SIZE_BYTES: usize = 10 * 1024 * 1024; const ADMIN_SYSTEM_IMPORT_MAX_SIZE_BYTES: usize = 10 * 1024 * 1024;
const MIN_ADMIN_SYSTEM_IMPORT_VERSION: (u32, u32) = (2, 2);
fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) { fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) {
( (
@@ -436,12 +435,19 @@ fn imported_system_export_version(version: Option<&Value>) -> Result<(u32, u32),
Ok((major, minor)) Ok((major, minor))
} }
fn validate_imported_system_export_version(version: Option<&Value>) -> Result<(), String> { fn validate_imported_system_users_export_version(version: Option<&Value>) -> Result<(), String> {
let parsed = imported_system_export_version(version)?; let Some(Value::String(raw_version)) = version else {
if parsed < MIN_ADMIN_SYSTEM_IMPORT_VERSION { return Err("version 必须是 x.y 字符串".to_string());
};
let normalized = raw_version.trim();
if normalized.is_empty() {
return Err("version 必须是 x.y 字符串".to_string());
}
let _ = imported_system_export_version(version)?;
if !ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS.contains(&normalized) {
return Err(format!( return Err(format!(
"version {}.{} 已不再支持;仅支持 2.2+ 导出格式", "不支持的用户数据版本: {normalized},支持的版本: {}",
parsed.0, parsed.1 ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS.join(", ")
)); ));
} }
Ok(()) Ok(())
@@ -1687,7 +1693,9 @@ impl<'a> AdminAppState<'a> {
}; };
} }
invalid_value!(validate_imported_system_export_version(root.get("version"))); invalid_value!(validate_imported_system_users_export_version(
root.get("version")
));
let mut stats = AdminSystemUsersImportStats::default(); let mut stats = AdminSystemUsersImportStats::default();
@@ -2428,18 +2436,18 @@ mod tests {
use super::{ use super::{
imported_optional_bool, imported_optional_f64, imported_optional_i32, imported_optional_bool, imported_optional_f64, imported_optional_i32,
imported_optional_u64, imported_string_list_from_value, imported_optional_u64, imported_string_list_from_value,
validate_imported_system_export_version, validate_imported_system_users_export_version,
}; };
#[test] #[test]
fn import_requires_supported_export_version() { fn users_import_requires_supported_export_version() {
assert!(validate_imported_system_export_version(Some(&json!("2.2"))).is_ok()); assert!(validate_imported_system_users_export_version(Some(&json!("1.3"))).is_ok());
assert_eq!( assert_eq!(
validate_imported_system_export_version(Some(&json!("2.1"))).unwrap_err(), validate_imported_system_users_export_version(Some(&json!("2.2"))).unwrap_err(),
"version 2.1 已不再支持;仅支持 2.2+ 导出格式" "不支持的用户数据版本: 2.2,支持的版本: 1.3"
); );
assert_eq!( assert_eq!(
validate_imported_system_export_version(Some(&json!(null))).unwrap_err(), validate_imported_system_users_export_version(Some(&json!(null))).unwrap_err(),
"version 必须是 x.y 字符串" "version 必须是 x.y 字符串"
); );
} }

View File

@@ -575,7 +575,7 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ .json(&json!({
"version": "2.2", "version": "1.3",
"merge_mode": "overwrite", "merge_mode": "overwrite",
"users": [{ "users": [{
"email": "alice@example.com", "email": "alice@example.com",
@@ -867,7 +867,7 @@ async fn gateway_rejects_legacy_user_import_string_bool_field() {
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ .json(&json!({
"version": "2.2", "version": "1.3",
"merge_mode": "overwrite", "merge_mode": "overwrite",
"users": [{ "users": [{
"email": "legacy@example.com", "email": "legacy@example.com",

View File

@@ -40,6 +40,8 @@ pub struct AdminEmailTemplateUpdate {
pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2"; pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2";
pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &[ADMIN_SYSTEM_CONFIG_EXPORT_VERSION]; pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &[ADMIN_SYSTEM_CONFIG_EXPORT_VERSION];
pub const ADMIN_SYSTEM_USERS_EXPORT_VERSION: &str = "1.3";
pub const ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS: &[&str] = &[ADMIN_SYSTEM_USERS_EXPORT_VERSION];
pub const ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[ pub const ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[
"api_key", "api_key",
"password", "password",

View File

@@ -14,6 +14,31 @@ import type { SystemConfig } from './useSystemConfig'
// 文件大小限制 (10MB) // 文件大小限制 (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_FILE_SIZE = 10 * 1024 * 1024
type JsonObject = Record<string, unknown>
function asJsonObject(value: unknown): JsonObject | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: null
}
function hasArrayField(value: JsonObject, key: string): boolean {
return Array.isArray(value[key])
}
function looksLikeConfigExport(value: JsonObject): boolean {
return hasArrayField(value, 'global_models')
|| hasArrayField(value, 'providers')
|| hasArrayField(value, 'proxy_nodes')
|| hasArrayField(value, 'oauth_providers')
|| hasArrayField(value, 'system_configs')
|| Object.prototype.hasOwnProperty.call(value, 'ldap_config')
}
function looksLikeUsersExport(value: JsonObject): boolean {
return hasArrayField(value, 'users') || hasArrayField(value, 'standalone_keys')
}
export function useConfigExportImport(systemConfig: { value: SystemConfig }) { export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const { success, error } = useToast() const { success, error } = useToast()
@@ -83,13 +108,28 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const content = e.target?.result as string const content = e.target?.result as string
const data = JSON.parse(content) as ConfigExportData const root = asJsonObject(JSON.parse(content))
if (!root) {
error('无效的配置文件JSON 顶层必须是对象')
return
}
if (!data.version) { if (looksLikeUsersExport(root) && !looksLikeConfigExport(root)) {
error('这是用户数据导出文件,请使用“导入用户数据”')
return
}
if (!root.version) {
error('无效的配置文件:缺少版本信息') error('无效的配置文件:缺少版本信息')
return return
} }
if (!looksLikeConfigExport(root)) {
error('无效的配置文件:未找到配置导出内容')
return
}
const data = root as unknown as ConfigExportData
importPreview.value = data importPreview.value = data
mergeMode.value = 'skip' mergeMode.value = 'skip'
importDialogOpen.value = true importDialogOpen.value = true
@@ -170,8 +210,33 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const content = e.target?.result as string const content = e.target?.result as string
const data = JSON.parse(content) as UsersExportData const root = asJsonObject(JSON.parse(content))
if (!root) {
error('无效的用户数据文件JSON 顶层必须是对象')
return
}
if (looksLikeConfigExport(root) && !looksLikeUsersExport(root)) {
error('这是配置导出文件,请使用“导入配置”')
return
}
if (!root.version) {
error('无效的用户数据文件:缺少版本信息')
return
}
if (!Array.isArray(root.users)) {
error('无效的用户数据文件:缺少 users 数组')
return
}
if (root.standalone_keys != null && !Array.isArray(root.standalone_keys)) {
error('无效的用户数据文件standalone_keys 必须是数组')
return
}
const data = root as unknown as UsersExportData
importUsersPreview.value = data importUsersPreview.value = data
usersMergeMode.value = 'skip' usersMergeMode.value = 'skip'
importUsersDialogOpen.value = true importUsersDialogOpen.value = true