912 lines
27 KiB
Rust
912 lines
27 KiB
Rust
use crate::api::context;
|
||
use crate::api::envelope::Envelope;
|
||
use crate::error::{AppError, ErrorCode};
|
||
use crate::services::billing;
|
||
use crate::services::{credentials, mail, settings};
|
||
use crate::state::AppState;
|
||
|
||
use axum::extract::{ConnectInfo, Path, Query, State};
|
||
use axum::http::HeaderMap;
|
||
use axum::routing::{delete, get, post, put};
|
||
use axum::{Json, Router};
|
||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||
use chrono::{DateTime, Duration, Utc};
|
||
use rand::RngCore;
|
||
use serde::{Deserialize, Serialize};
|
||
use sqlx::FromRow;
|
||
use std::collections::HashMap;
|
||
use std::net::SocketAddr;
|
||
use uuid::Uuid;
|
||
|
||
pub fn router() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/user/profile", get(get_profile))
|
||
.route("/user/profile", put(update_profile))
|
||
.route("/user/password", put(update_password))
|
||
.route("/user/history", get(list_history))
|
||
.route("/user/api-keys", get(list_api_keys))
|
||
.route("/user/api-keys", post(create_api_key))
|
||
.route("/user/api-keys/{key_id}/rotate", post(rotate_api_key))
|
||
.route("/user/api-keys/{key_id}", delete(disable_api_key))
|
||
}
|
||
|
||
#[derive(Debug, FromRow, Serialize)]
|
||
struct ApiKeyView {
|
||
id: Uuid,
|
||
name: String,
|
||
key_prefix: String,
|
||
permissions: serde_json::Value,
|
||
rate_limit: i32,
|
||
is_active: bool,
|
||
last_used_at: Option<chrono::DateTime<chrono::Utc>>,
|
||
last_used_ip: Option<String>,
|
||
created_at: chrono::DateTime<chrono::Utc>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct ApiKeyListResponse {
|
||
api_keys: Vec<ApiKeyView>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct UserView {
|
||
id: Uuid,
|
||
email: String,
|
||
username: String,
|
||
role: String,
|
||
email_verified: bool,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct MessageResponse {
|
||
message: String,
|
||
}
|
||
|
||
async fn get_profile(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
) -> Result<Json<Envelope<UserView>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let user_id = match principal {
|
||
context::Principal::User { user_id, .. } => user_id,
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
#[derive(Debug, FromRow)]
|
||
struct UserRow {
|
||
id: Uuid,
|
||
email: String,
|
||
username: String,
|
||
role: String,
|
||
email_verified_at: Option<DateTime<Utc>>,
|
||
}
|
||
|
||
let user = sqlx::query_as::<_, UserRow>(
|
||
r#"
|
||
SELECT id, email, username, role::text AS role, email_verified_at
|
||
FROM users
|
||
WHERE id = $1
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||
|
||
let verification_required = settings::email_verification_required(&state).await?;
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: UserView {
|
||
id: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
role: user.role,
|
||
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||
},
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct UpdateProfileRequest {
|
||
email: Option<String>,
|
||
username: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct UpdateProfileResponse {
|
||
user: UserView,
|
||
message: String,
|
||
}
|
||
|
||
async fn update_profile(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
Json(req): Json<UpdateProfileRequest>,
|
||
) -> Result<Json<Envelope<UpdateProfileResponse>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let user_id = match principal {
|
||
context::Principal::User { user_id, .. } => user_id,
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
if req.email.is_none() && req.username.is_none() {
|
||
return Err(AppError::new(ErrorCode::InvalidRequest, "未提供可更新字段"));
|
||
}
|
||
|
||
#[derive(Debug, FromRow)]
|
||
struct UserRow {
|
||
id: Uuid,
|
||
email: String,
|
||
username: String,
|
||
role: String,
|
||
email_verified_at: Option<DateTime<Utc>>,
|
||
}
|
||
|
||
let user = sqlx::query_as::<_, UserRow>(
|
||
r#"
|
||
SELECT id, email, username, role::text AS role, email_verified_at
|
||
FROM users
|
||
WHERE id = $1
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||
|
||
let mut next_email = user.email.clone();
|
||
let mut next_username = user.username.clone();
|
||
let mut email_changed = false;
|
||
let verification_required = settings::email_verification_required(&state).await?;
|
||
|
||
if let Some(email) = req.email.as_ref() {
|
||
let email = email.trim().to_lowercase();
|
||
credentials::validate_email(&email)?;
|
||
if email != user.email {
|
||
next_email = email;
|
||
email_changed = true;
|
||
}
|
||
}
|
||
|
||
if let Some(username) = req.username.as_ref() {
|
||
let username = username.trim().to_string();
|
||
credentials::validate_username(&username)?;
|
||
if username != user.username {
|
||
next_username = username;
|
||
}
|
||
}
|
||
|
||
if next_email == user.email && next_username == user.username {
|
||
return Ok(Json(Envelope {
|
||
success: true,
|
||
data: UpdateProfileResponse {
|
||
user: UserView {
|
||
id: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
role: user.role,
|
||
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||
},
|
||
message: "暂无更新".to_string(),
|
||
},
|
||
}));
|
||
}
|
||
|
||
let mut tx = state
|
||
.db
|
||
.begin()
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||
|
||
let email_verified_at = if email_changed && verification_required {
|
||
None
|
||
} else if email_changed {
|
||
Some(Utc::now())
|
||
} else {
|
||
user.email_verified_at
|
||
};
|
||
|
||
let updated = sqlx::query_as::<_, UserRow>(
|
||
r#"
|
||
UPDATE users
|
||
SET email = $2,
|
||
username = $3,
|
||
email_verified_at = $4,
|
||
updated_at = NOW()
|
||
WHERE id = $1
|
||
RETURNING id, email, username, role::text AS role, email_verified_at
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(&next_email)
|
||
.bind(&next_username)
|
||
.bind(email_verified_at)
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.map_err(map_unique_violation)?;
|
||
|
||
let mut verification_link: Option<String> = None;
|
||
if email_changed && verification_required {
|
||
let token = credentials::generate_token();
|
||
let token_hash = credentials::sha256_hex(&token);
|
||
let expires_at = Utc::now() + Duration::hours(24);
|
||
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
||
VALUES ($1, $2, $3)
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(token_hash)
|
||
.bind(expires_at)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|err| {
|
||
AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err)
|
||
})?;
|
||
|
||
verification_link = Some(format!(
|
||
"{}/verify-email?token={}",
|
||
state.config.public_base_url, token
|
||
));
|
||
}
|
||
|
||
tx.commit()
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||
|
||
if let Some(link) = verification_link.as_deref() {
|
||
mail::send_verification_email(&state, &updated.email, &updated.username, link)
|
||
.await
|
||
.map_err(|err| {
|
||
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
||
})?;
|
||
}
|
||
|
||
let message = if email_changed && verification_required {
|
||
"资料已更新,请验证新邮箱".to_string()
|
||
} else {
|
||
"资料已更新".to_string()
|
||
};
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: UpdateProfileResponse {
|
||
user: UserView {
|
||
id: updated.id,
|
||
email: updated.email,
|
||
username: updated.username,
|
||
role: updated.role,
|
||
email_verified: updated.email_verified_at.is_some() || !verification_required,
|
||
},
|
||
message,
|
||
},
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct UpdatePasswordRequest {
|
||
current_password: String,
|
||
new_password: String,
|
||
}
|
||
|
||
async fn update_password(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
Json(req): Json<UpdatePasswordRequest>,
|
||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let user_id = match principal {
|
||
context::Principal::User { user_id, .. } => user_id,
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
credentials::validate_password(&req.new_password)?;
|
||
|
||
#[derive(Debug, FromRow)]
|
||
struct PasswordRow {
|
||
password_hash: String,
|
||
}
|
||
|
||
let row = sqlx::query_as::<_, PasswordRow>("SELECT password_hash FROM users WHERE id = $1")
|
||
.bind(user_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||
|
||
if !credentials::verify_password(&req.current_password, &row.password_hash).await? {
|
||
return Err(AppError::new(ErrorCode::Unauthorized, "密码错误"));
|
||
}
|
||
|
||
let new_hash = credentials::hash_password(&req.new_password).await?;
|
||
sqlx::query(
|
||
"UPDATE users SET password_hash = $2, token_version = token_version + 1, updated_at = NOW() WHERE id = $1",
|
||
)
|
||
.bind(user_id)
|
||
.bind(new_hash)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: MessageResponse {
|
||
message: "密码已更新,请重新登录以确保安全".to_string(),
|
||
},
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct HistoryQuery {
|
||
page: Option<u32>,
|
||
limit: Option<u32>,
|
||
status: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct HistoryFileView {
|
||
file_id: Uuid,
|
||
original_name: String,
|
||
original_size: i64,
|
||
compressed_size: Option<i64>,
|
||
saved_percent: Option<f64>,
|
||
status: String,
|
||
output_format: String,
|
||
error_message: Option<String>,
|
||
download_url: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct HistoryTaskView {
|
||
task_id: Uuid,
|
||
status: String,
|
||
source: String,
|
||
progress: i32,
|
||
total_files: i32,
|
||
completed_files: i32,
|
||
failed_files: i32,
|
||
created_at: DateTime<Utc>,
|
||
completed_at: Option<DateTime<Utc>>,
|
||
expires_at: DateTime<Utc>,
|
||
download_all_url: Option<String>,
|
||
files: Vec<HistoryFileView>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct HistoryResponse {
|
||
tasks: Vec<HistoryTaskView>,
|
||
page: u32,
|
||
limit: u32,
|
||
total: i64,
|
||
}
|
||
|
||
async fn list_history(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
Query(query): Query<HistoryQuery>,
|
||
) -> Result<Json<Envelope<HistoryResponse>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let user_id = match principal {
|
||
context::Principal::User { user_id, .. } => user_id,
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||
let page = query.page.unwrap_or(1).max(1);
|
||
let offset = (page - 1) * limit;
|
||
let status = super::normalize_task_status(query.status.as_deref())?;
|
||
|
||
let total: i64 = if let Some(status) = status {
|
||
sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status = $2::task_status",
|
||
)
|
||
.bind(user_id)
|
||
.bind(status)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||
} else {
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE user_id = $1")
|
||
.bind(user_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||
};
|
||
|
||
#[derive(Debug, FromRow)]
|
||
struct TaskRow {
|
||
id: Uuid,
|
||
status: String,
|
||
source: String,
|
||
total_files: i32,
|
||
completed_files: i32,
|
||
failed_files: i32,
|
||
created_at: DateTime<Utc>,
|
||
completed_at: Option<DateTime<Utc>>,
|
||
expires_at: DateTime<Utc>,
|
||
}
|
||
|
||
let tasks: Vec<TaskRow> = if let Some(status) = status {
|
||
sqlx::query_as::<_, TaskRow>(
|
||
r#"
|
||
SELECT
|
||
id,
|
||
status::text AS status,
|
||
source::text AS source,
|
||
total_files,
|
||
completed_files,
|
||
failed_files,
|
||
created_at,
|
||
completed_at,
|
||
expires_at
|
||
FROM tasks
|
||
WHERE user_id = $1 AND status = $2::task_status
|
||
ORDER BY created_at DESC
|
||
LIMIT $3 OFFSET $4
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(status)
|
||
.bind(limit as i64)
|
||
.bind(offset as i64)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||
} else {
|
||
sqlx::query_as::<_, TaskRow>(
|
||
r#"
|
||
SELECT
|
||
id,
|
||
status::text AS status,
|
||
source::text AS source,
|
||
total_files,
|
||
completed_files,
|
||
failed_files,
|
||
created_at,
|
||
completed_at,
|
||
expires_at
|
||
FROM tasks
|
||
WHERE user_id = $1
|
||
ORDER BY created_at DESC
|
||
LIMIT $2 OFFSET $3
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(limit as i64)
|
||
.bind(offset as i64)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||
};
|
||
|
||
#[derive(Debug, FromRow)]
|
||
struct FileRow {
|
||
task_id: Uuid,
|
||
id: Uuid,
|
||
original_name: String,
|
||
original_size: i64,
|
||
compressed_size: Option<i64>,
|
||
saved_percent: Option<f64>,
|
||
status: String,
|
||
output_format: String,
|
||
error_message: Option<String>,
|
||
has_storage: bool,
|
||
}
|
||
|
||
let now = Utc::now();
|
||
let task_ids = tasks.iter().map(|task| task.id).collect::<Vec<_>>();
|
||
let files = if task_ids.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
sqlx::query_as::<_, FileRow>(
|
||
r#"
|
||
SELECT
|
||
task_id,
|
||
id,
|
||
original_name,
|
||
original_size,
|
||
compressed_size,
|
||
saved_percent::float8 AS saved_percent,
|
||
status::text AS status,
|
||
output_format,
|
||
error_message,
|
||
COALESCE(storage_key, storage_path) IS NOT NULL AS has_storage
|
||
FROM task_files
|
||
WHERE task_id = ANY($1)
|
||
ORDER BY task_id, created_at ASC
|
||
"#,
|
||
)
|
||
.bind(&task_ids)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?
|
||
};
|
||
let mut files_by_task = HashMap::<Uuid, Vec<FileRow>>::new();
|
||
for file in files {
|
||
files_by_task.entry(file.task_id).or_default().push(file);
|
||
}
|
||
|
||
let mut result_tasks = Vec::with_capacity(tasks.len());
|
||
for task in tasks {
|
||
let file_views = files_by_task
|
||
.remove(&task.id)
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|file| HistoryFileView {
|
||
file_id: file.id,
|
||
original_name: file.original_name,
|
||
original_size: file.original_size,
|
||
compressed_size: file.compressed_size,
|
||
saved_percent: file.saved_percent,
|
||
status: file.status.clone(),
|
||
output_format: file.output_format,
|
||
error_message: file.error_message,
|
||
download_url: if file.status == "completed"
|
||
&& file.has_storage
|
||
&& task.expires_at > now
|
||
{
|
||
Some(format!("/downloads/{}", file.id))
|
||
} else {
|
||
None
|
||
},
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
let progress = if task.total_files > 0 {
|
||
((task.completed_files + task.failed_files) * 100 / task.total_files).clamp(0, 100)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let download_all_url = if task.status == "completed" && task.expires_at > now {
|
||
Some(format!("/downloads/tasks/{}", task.id))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
result_tasks.push(HistoryTaskView {
|
||
task_id: task.id,
|
||
status: task.status,
|
||
source: task.source,
|
||
progress,
|
||
total_files: task.total_files,
|
||
completed_files: task.completed_files,
|
||
failed_files: task.failed_files,
|
||
created_at: task.created_at,
|
||
completed_at: task.completed_at,
|
||
expires_at: task.expires_at,
|
||
download_all_url,
|
||
files: file_views,
|
||
});
|
||
}
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: HistoryResponse {
|
||
tasks: result_tasks,
|
||
page,
|
||
limit,
|
||
total,
|
||
},
|
||
}))
|
||
}
|
||
|
||
async fn list_api_keys(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
) -> Result<Json<Envelope<ApiKeyListResponse>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let (user_id, _email_verified) = match principal {
|
||
context::Principal::User {
|
||
user_id,
|
||
email_verified,
|
||
..
|
||
} => (user_id, email_verified),
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
let rows = sqlx::query_as::<_, ApiKeyView>(
|
||
r#"
|
||
SELECT
|
||
id,
|
||
name,
|
||
key_prefix,
|
||
permissions,
|
||
rate_limit,
|
||
is_active,
|
||
last_used_at,
|
||
last_used_ip::text AS last_used_ip,
|
||
created_at
|
||
FROM api_keys
|
||
WHERE user_id = $1
|
||
ORDER BY created_at DESC
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?;
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: ApiKeyListResponse { api_keys: rows },
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct CreateApiKeyRequest {
|
||
name: String,
|
||
permissions: Option<Vec<String>>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct CreateApiKeyResponse {
|
||
id: Uuid,
|
||
name: String,
|
||
key_prefix: String,
|
||
key: String,
|
||
message: String,
|
||
}
|
||
|
||
async fn create_api_key(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
Json(req): Json<CreateApiKeyRequest>,
|
||
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
|
||
if req.name.trim().is_empty() || req.name.len() > 100 {
|
||
return Err(AppError::new(ErrorCode::InvalidRequest, "name 不合法"));
|
||
}
|
||
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let (user_id, email_verified) = match principal {
|
||
context::Principal::User {
|
||
user_id,
|
||
email_verified,
|
||
..
|
||
} => (user_id, email_verified),
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
if !email_verified {
|
||
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
|
||
}
|
||
if !settings::runtime_policy(&state)
|
||
.await?
|
||
.features
|
||
.api_key_enabled
|
||
{
|
||
return Err(AppError::new(
|
||
ErrorCode::Forbidden,
|
||
"API Key 功能当前已关闭",
|
||
));
|
||
}
|
||
|
||
let billing = billing::get_user_billing(&state, user_id).await?;
|
||
if !billing.plan.feature_api_enabled {
|
||
return Err(AppError::new(
|
||
ErrorCode::Forbidden,
|
||
"当前套餐未开通 API Key",
|
||
));
|
||
}
|
||
|
||
let permissions = normalize_permissions(req.permissions)?;
|
||
|
||
let (full_key, key_prefix) = generate_api_key();
|
||
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
|
||
|
||
let row_id: Uuid = sqlx::query_scalar(
|
||
r#"
|
||
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, permissions, rate_limit)
|
||
VALUES ($1, $2, $3, $4, $5, 100)
|
||
RETURNING id
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(req.name.trim())
|
||
.bind(&key_prefix)
|
||
.bind(key_hash)
|
||
.bind(&permissions)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建 API Key 失败").with_source(err))?;
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: CreateApiKeyResponse {
|
||
id: row_id,
|
||
name: req.name.trim().to_string(),
|
||
key_prefix,
|
||
key: full_key,
|
||
message: "请保存此 Key,它只会显示一次".to_string(),
|
||
},
|
||
}))
|
||
}
|
||
|
||
async fn disable_api_key(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
Path(key_id): Path<Uuid>,
|
||
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let user_id = match principal {
|
||
context::Principal::User { user_id, .. } => user_id,
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
let result =
|
||
sqlx::query("UPDATE api_keys SET is_active = false WHERE id = $1 AND user_id = $2")
|
||
.bind(key_id)
|
||
.bind(user_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|err| {
|
||
AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err)
|
||
})?;
|
||
|
||
if result.rows_affected() == 0 {
|
||
return Err(AppError::new(ErrorCode::NotFound, "API Key 不存在"));
|
||
}
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: serde_json::json!({ "message": "已禁用" }),
|
||
}))
|
||
}
|
||
|
||
async fn rotate_api_key(
|
||
State(state): State<AppState>,
|
||
jar: axum_extra::extract::cookie::CookieJar,
|
||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||
headers: HeaderMap,
|
||
Path(key_id): Path<Uuid>,
|
||
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
|
||
let ip = context::client_ip(&headers, addr.ip());
|
||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||
|
||
let (user_id, email_verified) = match principal {
|
||
context::Principal::User {
|
||
user_id,
|
||
email_verified,
|
||
..
|
||
} => (user_id, email_verified),
|
||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||
};
|
||
|
||
if !email_verified {
|
||
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
|
||
}
|
||
if !settings::runtime_policy(&state)
|
||
.await?
|
||
.features
|
||
.api_key_enabled
|
||
{
|
||
return Err(AppError::new(
|
||
ErrorCode::Forbidden,
|
||
"API Key 功能当前已关闭",
|
||
));
|
||
}
|
||
|
||
let (full_key, key_prefix) = generate_api_key();
|
||
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
|
||
|
||
#[derive(Debug, FromRow)]
|
||
struct RotateRow {
|
||
id: Uuid,
|
||
name: String,
|
||
}
|
||
|
||
let row = sqlx::query_as::<_, RotateRow>(
|
||
r#"
|
||
UPDATE api_keys
|
||
SET key_prefix = $1,
|
||
key_hash = $2,
|
||
is_active = true
|
||
WHERE id = $3 AND user_id = $4
|
||
RETURNING id, name
|
||
"#,
|
||
)
|
||
.bind(&key_prefix)
|
||
.bind(key_hash)
|
||
.bind(key_id)
|
||
.bind(user_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?
|
||
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "API Key 不存在"))?;
|
||
|
||
Ok(Json(Envelope {
|
||
success: true,
|
||
data: CreateApiKeyResponse {
|
||
id: row.id,
|
||
name: row.name,
|
||
key_prefix,
|
||
key: full_key,
|
||
message: "请保存此 Key,它只会显示一次".to_string(),
|
||
},
|
||
}))
|
||
}
|
||
|
||
fn generate_api_key() -> (String, String) {
|
||
let mut prefix_bytes = [0u8; 4];
|
||
rand::rngs::OsRng.fill_bytes(&mut prefix_bytes);
|
||
let prefix = hex::encode(prefix_bytes);
|
||
let key_prefix = format!("if_live_{prefix}");
|
||
|
||
let mut secret_bytes = [0u8; 32];
|
||
rand::rngs::OsRng.fill_bytes(&mut secret_bytes);
|
||
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||
|
||
let full = format!("{key_prefix}_{secret}");
|
||
(full, key_prefix)
|
||
}
|
||
|
||
fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value, AppError> {
|
||
let allowed = ["compress", "batch_compress"];
|
||
|
||
let mut perms = Vec::<String>::new();
|
||
if let Some(values) = input {
|
||
for value in values {
|
||
let v = value.trim().to_ascii_lowercase();
|
||
if v.is_empty() {
|
||
continue;
|
||
}
|
||
if !allowed.contains(&v.as_str()) {
|
||
return Err(AppError::new(
|
||
ErrorCode::InvalidRequest,
|
||
format!("不支持的权限: {v}"),
|
||
));
|
||
}
|
||
if !perms.contains(&v) {
|
||
perms.push(v);
|
||
}
|
||
}
|
||
}
|
||
|
||
if perms.is_empty() {
|
||
perms.push("compress".to_string());
|
||
}
|
||
|
||
Ok(serde_json::json!(perms))
|
||
}
|
||
|
||
fn map_unique_violation(err: sqlx::Error) -> AppError {
|
||
if let sqlx::Error::Database(db_err) = &err {
|
||
if let Some(code) = db_err.code() {
|
||
if code == "23505" {
|
||
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
|
||
}
|
||
}
|
||
}
|
||
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
|
||
}
|