mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
431 lines
14 KiB
Rust
431 lines
14 KiB
Rust
use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL;
|
|
use crate::handlers::admin::shared::AdminTypedObjectPatch;
|
|
use crate::handlers::shared::{deserialize_optional_string_list_patch, normalize_ip_rules};
|
|
use axum::{
|
|
body::Body,
|
|
http,
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use regex::Regex;
|
|
use serde_json::{json, Value};
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub(super) struct AdminCreateUserApiKeyRequest {
|
|
#[serde(default)]
|
|
pub(super) name: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) allowed_providers: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub(super) allowed_api_formats: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub(super) allowed_models: Option<Vec<String>>,
|
|
#[serde(default, alias = "allowed_ips")]
|
|
pub(super) ip_rules: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub(super) rate_limit: Option<i32>,
|
|
#[serde(default)]
|
|
pub(super) concurrent_limit: Option<i32>,
|
|
#[serde(default)]
|
|
pub(super) expire_days: Option<i32>,
|
|
#[serde(default)]
|
|
pub(super) expires_at: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) initial_balance_usd: Option<f64>,
|
|
#[serde(default)]
|
|
pub(super) unlimited_balance: Option<bool>,
|
|
#[serde(default)]
|
|
pub(super) is_standalone: Option<bool>,
|
|
#[serde(default)]
|
|
pub(super) auto_delete_on_expiry: Option<bool>,
|
|
#[serde(default)]
|
|
pub(super) feature_settings: Option<Value>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub(super) struct AdminUpdateUserApiKeyRequest {
|
|
#[serde(default)]
|
|
pub(super) name: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) rate_limit: Option<i32>,
|
|
#[serde(default)]
|
|
pub(super) concurrent_limit: Option<i32>,
|
|
#[serde(default)]
|
|
pub(super) feature_settings: Option<Option<Value>>,
|
|
#[serde(
|
|
default,
|
|
alias = "allowed_ips",
|
|
deserialize_with = "deserialize_optional_string_list_patch"
|
|
)]
|
|
pub(super) ip_rules: Option<Option<Vec<String>>>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub(super) struct AdminToggleUserApiKeyLockRequest {
|
|
#[serde(default)]
|
|
pub(super) locked: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub(super) struct AdminCreateUserRequest {
|
|
pub(super) username: String,
|
|
pub(super) password: String,
|
|
#[serde(default)]
|
|
pub(super) email: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) role: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) initial_gift_usd: Option<f64>,
|
|
#[serde(default)]
|
|
pub(super) unlimited: bool,
|
|
#[serde(default)]
|
|
pub(super) group_ids: Vec<String>,
|
|
#[serde(default)]
|
|
pub(super) feature_settings: Option<Value>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub(super) struct AdminUpdateUserRequest {
|
|
#[serde(default)]
|
|
pub(super) email: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) username: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) password: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) role: Option<String>,
|
|
#[serde(default)]
|
|
pub(super) unlimited: Option<bool>,
|
|
#[serde(default)]
|
|
pub(super) group_ids: Vec<String>,
|
|
#[serde(default)]
|
|
pub(super) is_active: Option<bool>,
|
|
#[serde(default)]
|
|
pub(super) feature_settings: Option<Option<Value>>,
|
|
}
|
|
|
|
pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;
|
|
|
|
const DISABLED_USER_POLICY_FIELDS: &[&str] = &[
|
|
"allowed_providers",
|
|
"allowed_providers_mode",
|
|
"allowed_api_formats",
|
|
"allowed_api_formats_mode",
|
|
"allowed_models",
|
|
"allowed_models_mode",
|
|
"rate_limit",
|
|
"rate_limit_mode",
|
|
];
|
|
|
|
pub(super) fn disabled_user_policy_field(
|
|
object: &serde_json::Map<String, serde_json::Value>,
|
|
) -> Option<&'static str> {
|
|
DISABLED_USER_POLICY_FIELDS
|
|
.iter()
|
|
.copied()
|
|
.find(|field| object.contains_key(*field))
|
|
}
|
|
|
|
pub(super) fn disabled_user_policy_detail(field: &str) -> String {
|
|
format!("{field} 已停用,请通过用户分组管理访问权限")
|
|
}
|
|
|
|
pub(super) fn build_admin_users_data_unavailable_response() -> Response<Body> {
|
|
(
|
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
|
Json(json!({ "detail": ADMIN_USERS_DATA_UNAVAILABLE_DETAIL })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub(super) fn build_admin_users_read_only_response(detail: &'static str) -> Response<Body> {
|
|
(
|
|
http::StatusCode::CONFLICT,
|
|
Json(json!({
|
|
"detail": detail,
|
|
"error_code": "read_only_mode",
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub(super) fn build_admin_users_bad_request_response(detail: impl Into<String>) -> Response<Body> {
|
|
(
|
|
http::StatusCode::BAD_REQUEST,
|
|
Json(json!({ "detail": detail.into() })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub(super) fn normalize_admin_optional_user_email(
|
|
value: Option<&str>,
|
|
) -> Result<Option<String>, String> {
|
|
let Some(value) = value else {
|
|
return Ok(None);
|
|
};
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let normalized = value.to_ascii_lowercase();
|
|
let pattern = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
|
.expect("email regex should compile");
|
|
if !pattern.is_match(&normalized) {
|
|
return Err("邮箱格式无效".to_string());
|
|
}
|
|
Ok(Some(normalized))
|
|
}
|
|
|
|
pub(super) fn normalize_admin_username(value: &str) -> Result<String, String> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Err("用户名不能为空".to_string());
|
|
}
|
|
if value.len() < 3 {
|
|
return Err("用户名长度至少为3个字符".to_string());
|
|
}
|
|
if value.len() > 30 {
|
|
return Err("用户名长度不能超过30个字符".to_string());
|
|
}
|
|
let pattern = Regex::new(r"^[a-zA-Z0-9_.-]+$").expect("username regex should compile");
|
|
if !pattern.is_match(value) {
|
|
return Err("用户名只能包含字母、数字、下划线、连字符和点号".to_string());
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
pub(super) fn validate_admin_user_password(password: &str, policy: &str) -> Result<(), String> {
|
|
if password.is_empty() {
|
|
return Err("密码不能为空".to_string());
|
|
}
|
|
if password.as_bytes().len() > 72 {
|
|
return Err("密码长度不能超过72字节".to_string());
|
|
}
|
|
let min_len = if matches!(policy, "medium" | "strong") {
|
|
8
|
|
} else {
|
|
6
|
|
};
|
|
if password.chars().count() < min_len {
|
|
return Err(format!("密码长度至少为{min_len}个字符"));
|
|
}
|
|
if policy == "medium" {
|
|
if !password.chars().any(|ch| ch.is_ascii_alphabetic()) {
|
|
return Err("密码必须包含至少一个字母".to_string());
|
|
}
|
|
if !password.chars().any(|ch| ch.is_ascii_digit()) {
|
|
return Err("密码必须包含至少一个数字".to_string());
|
|
}
|
|
} else if policy == "strong" {
|
|
if !password.chars().any(|ch| ch.is_ascii_uppercase()) {
|
|
return Err("密码必须包含至少一个大写字母".to_string());
|
|
}
|
|
if !password.chars().any(|ch| ch.is_ascii_lowercase()) {
|
|
return Err("密码必须包含至少一个小写字母".to_string());
|
|
}
|
|
if !password.chars().any(|ch| ch.is_ascii_digit()) {
|
|
return Err("密码必须包含至少一个数字".to_string());
|
|
}
|
|
if !password.chars().any(|ch| !ch.is_ascii_alphanumeric()) {
|
|
return Err("密码必须包含至少一个特殊字符".to_string());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result<String, String> {
|
|
let role = value
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or("user");
|
|
match crate::roles::normalize_assignable_user_role(role) {
|
|
Some(role) => Ok(role.to_string()),
|
|
None => Err("角色参数不合法".to_string()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn normalize_admin_user_string_list(
|
|
value: Option<Vec<String>>,
|
|
field_name: &str,
|
|
) -> Result<Option<Vec<String>>, String> {
|
|
let Some(values) = value else {
|
|
return Ok(None);
|
|
};
|
|
let mut normalized = Vec::new();
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
for item in values {
|
|
let item = item.trim();
|
|
if item.is_empty() {
|
|
return Err(format!("{field_name} 不能为空"));
|
|
}
|
|
if seen.insert(item.to_string()) {
|
|
normalized.push(item.to_string());
|
|
}
|
|
}
|
|
Ok(Some(normalized))
|
|
}
|
|
|
|
pub(crate) fn normalize_admin_user_api_formats(
|
|
value: Option<Vec<String>>,
|
|
) -> Result<Option<Vec<String>>, String> {
|
|
let Some(values) = value else {
|
|
return Ok(None);
|
|
};
|
|
let mut normalized = Vec::new();
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
for item in values {
|
|
let item = item.trim();
|
|
if item.is_empty() {
|
|
return Err("allowed_api_formats 不能为空".to_string());
|
|
}
|
|
if !looks_like_admin_api_format_signature(item) {
|
|
return Err(format!("allowed_api_formats 格式无效: {item}"));
|
|
}
|
|
let Some(normalized_item) = crate::api::ai::normalize_admin_endpoint_signature(item) else {
|
|
return Err(format!("allowed_api_formats 格式无效: {item}"));
|
|
};
|
|
let normalized_item = normalized_item.to_string();
|
|
if seen.insert(normalized_item.clone()) {
|
|
normalized.push(normalized_item);
|
|
}
|
|
}
|
|
Ok(Some(normalized))
|
|
}
|
|
|
|
fn looks_like_admin_api_format_signature(value: &str) -> bool {
|
|
value
|
|
.split_once(':')
|
|
.is_some_and(|(family, kind)| !family.trim().is_empty() && !kind.trim().is_empty())
|
|
}
|
|
|
|
pub(crate) fn normalize_admin_user_ip_rules(
|
|
value: Option<Vec<String>>,
|
|
) -> Result<Option<Vec<String>>, String> {
|
|
normalize_ip_rules(value)
|
|
}
|
|
|
|
pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"inherit" | "unrestricted" | "specific" | "deny_all" => {
|
|
Ok(value.trim().to_ascii_lowercase())
|
|
}
|
|
_ => Err("权限列表模式不合法".to_string()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn normalize_admin_rate_limit_policy_mode(value: &str) -> Result<String, String> {
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"inherit" | "system" | "custom" => Ok(value.trim().to_ascii_lowercase()),
|
|
_ => Err("限速模式不合法".to_string()),
|
|
}
|
|
}
|
|
|
|
pub(super) fn legacy_admin_list_policy_mode(values: &Option<Vec<String>>) -> String {
|
|
if values.as_ref().is_some_and(|items| !items.is_empty()) {
|
|
"specific".to_string()
|
|
} else {
|
|
"unrestricted".to_string()
|
|
}
|
|
}
|
|
|
|
pub(super) fn legacy_admin_rate_limit_policy_mode(value: Option<i32>) -> String {
|
|
if value.is_some() {
|
|
"custom".to_string()
|
|
} else {
|
|
"system".to_string()
|
|
}
|
|
}
|
|
|
|
pub(super) fn normalize_admin_user_group_ids(values: Vec<String>) -> Vec<String> {
|
|
values
|
|
.into_iter()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.collect::<std::collections::BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
|
|
pub(super) fn admin_default_user_initial_gift(value: Option<&serde_json::Value>) -> f64 {
|
|
match value {
|
|
Some(serde_json::Value::Number(number)) => number.as_f64().unwrap_or(10.0),
|
|
Some(serde_json::Value::String(value)) => value.parse::<f64>().unwrap_or(10.0),
|
|
_ => 10.0,
|
|
}
|
|
}
|
|
|
|
pub(super) fn format_optional_datetime_iso8601(
|
|
value: Option<chrono::DateTime<chrono::Utc>>,
|
|
) -> Option<String> {
|
|
value.map(|value| value.to_rfc3339())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{normalize_admin_user_api_formats, AdminUpdateUserApiKeyRequest};
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn admin_user_api_formats_accept_current_canonical_signatures() {
|
|
assert_eq!(
|
|
normalize_admin_user_api_formats(Some(vec![
|
|
" OPENAI:RESPONSES ".to_string(),
|
|
"claude:messages".to_string(),
|
|
"gemini:generate_content".to_string(),
|
|
"jina:rerank".to_string(),
|
|
"openai:responses".to_string(),
|
|
]))
|
|
.expect("formats should normalize"),
|
|
Some(vec![
|
|
"openai:responses".to_string(),
|
|
"claude:messages".to_string(),
|
|
"gemini:generate_content".to_string(),
|
|
"jina:rerank".to_string(),
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_user_api_formats_reject_unsupported_signatures() {
|
|
for unsupported in [
|
|
"claude",
|
|
"openai",
|
|
"unknown:chat",
|
|
"openai:unknown",
|
|
"gemini:generate",
|
|
] {
|
|
assert!(
|
|
normalize_admin_user_api_formats(Some(vec![unsupported.to_string()])).is_err(),
|
|
"{unsupported} should be rejected"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_update_api_key_distinguishes_missing_null_and_present_ip_rules() {
|
|
let missing = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
|
|
"name": "unchanged-ip-rules",
|
|
}))
|
|
.expect("missing ip_rules should deserialize");
|
|
assert_eq!(missing.ip_rules, None);
|
|
|
|
let cleared = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
|
|
"ip_rules": null,
|
|
}))
|
|
.expect("null ip_rules should deserialize");
|
|
assert_eq!(cleared.ip_rules, Some(None));
|
|
|
|
let updated = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
|
|
"ip_rules": ["203.0.113.10", "10.0.0.0/24"],
|
|
}))
|
|
.expect("present ip_rules should deserialize");
|
|
assert_eq!(
|
|
updated.ip_rules,
|
|
Some(Some(vec![
|
|
"203.0.113.10".to_string(),
|
|
"10.0.0.0/24".to_string(),
|
|
])),
|
|
);
|
|
}
|
|
}
|