d8dd2f1857
移除外部OpenAPI中的API Key管理接口暴露 补齐编辑器项目列表、最近项目、重命名和删除外部接口 补齐素材直传凭证、素材对象确认和签名读取外部接口 复用站内编辑器生成、素材库和项目资源链路并按API Key账号归属 更新外部OpenAPI、后端契约和项目共享记忆文档
249 lines
7.6 KiB
Rust
249 lines
7.6 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, State},
|
|
http::StatusCode,
|
|
};
|
|
use platform_auth::hash_refresh_session_token;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string};
|
|
use spacetime_client::{
|
|
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
|
|
SpacetimeClientError,
|
|
};
|
|
|
|
use crate::{
|
|
api_response::json_success_body, auth::AuthenticatedAccessToken,
|
|
editor_project::current_utc_micros, http_error::AppError, request_context::RequestContext,
|
|
state::AppState,
|
|
};
|
|
|
|
const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-";
|
|
const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_";
|
|
const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18;
|
|
const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [
|
|
"editor:project",
|
|
"editor:canvas",
|
|
"editor:image-generate",
|
|
"editor:asset",
|
|
];
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalApiKeyCreateRequest {
|
|
name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalApiKeyPayload {
|
|
key_id: String,
|
|
name: String,
|
|
key_prefix: String,
|
|
scopes: Vec<String>,
|
|
created_at: String,
|
|
last_used_at: Option<String>,
|
|
revoked_at: Option<String>,
|
|
updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalApiKeyCreateResponse {
|
|
api_key: String,
|
|
key: ExternalApiKeyPayload,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalApiKeyListResponse {
|
|
keys: Vec<ExternalApiKeyPayload>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalApiKeyResponse {
|
|
key: ExternalApiKeyPayload,
|
|
}
|
|
|
|
pub async fn list_external_api_keys(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let keys = state
|
|
.spacetime_client()
|
|
.list_external_api_keys(authenticated.claims().user_id().to_string())
|
|
.await
|
|
.map_err(map_external_api_key_error)?
|
|
.into_iter()
|
|
.map(external_api_key_payload_from_record)
|
|
.collect();
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalApiKeyListResponse { keys },
|
|
))
|
|
}
|
|
|
|
pub async fn create_external_api_key(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<ExternalApiKeyCreateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let raw_key = generate_external_api_key_secret();
|
|
let key_prefix = external_api_key_prefix(raw_key.as_str());
|
|
let key = state
|
|
.spacetime_client()
|
|
.create_external_api_key(ExternalApiKeyCreateRecordInput {
|
|
key_id: build_prefixed_uuid_id(EXTERNAL_API_KEY_ID_PREFIX),
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
name: payload
|
|
.name
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| "外部 API Key".to_string()),
|
|
key_prefix,
|
|
key_hash: hash_external_api_key(raw_key.as_str()),
|
|
scopes: default_external_api_key_scopes(),
|
|
now_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_external_api_key_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalApiKeyCreateResponse {
|
|
api_key: raw_key,
|
|
key: external_api_key_payload_from_record(key),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn revoke_external_api_key(
|
|
State(state): State<AppState>,
|
|
Path(key_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let key = state
|
|
.spacetime_client()
|
|
.revoke_external_api_key(ExternalApiKeyRevokeRecordInput {
|
|
key_id,
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
revoked_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_external_api_key_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalApiKeyResponse {
|
|
key: external_api_key_payload_from_record(key),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub(crate) fn hash_external_api_key(raw_key: &str) -> String {
|
|
hash_refresh_session_token(raw_key)
|
|
}
|
|
|
|
pub(crate) fn default_external_api_key_scopes() -> Vec<String> {
|
|
EXTERNAL_API_KEY_SCOPES
|
|
.iter()
|
|
.map(|scope| (*scope).to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn generate_external_api_key_secret() -> String {
|
|
format!(
|
|
"{EXTERNAL_API_KEY_SECRET_PREFIX}{}.{}",
|
|
new_uuid_simple_string(),
|
|
new_uuid_simple_string()
|
|
)
|
|
}
|
|
|
|
fn external_api_key_prefix(raw_key: &str) -> String {
|
|
raw_key
|
|
.chars()
|
|
.take(EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS)
|
|
.collect()
|
|
}
|
|
|
|
fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> ExternalApiKeyPayload {
|
|
ExternalApiKeyPayload {
|
|
key_id: record.key_id,
|
|
name: record.name,
|
|
key_prefix: record.key_prefix,
|
|
scopes: record.scopes,
|
|
created_at: record.created_at,
|
|
last_used_at: record.last_used_at,
|
|
revoked_at: record.revoked_at,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError {
|
|
match error {
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("不存在") || message.contains("已失效") =>
|
|
{
|
|
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
|
|
"provider": "external-api-key",
|
|
"message": message,
|
|
}))
|
|
}
|
|
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
|
|
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
|
"provider": "external-api-key",
|
|
"message": message,
|
|
}))
|
|
}
|
|
SpacetimeClientError::Runtime(message) | SpacetimeClientError::Procedure(message) => {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "external-api-key",
|
|
"message": message,
|
|
}))
|
|
}
|
|
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "spacetimedb",
|
|
"message": other.to_string(),
|
|
})),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn generated_external_api_key_uses_public_prefix_only_for_display() {
|
|
let raw_key = generate_external_api_key_secret();
|
|
let key_prefix = external_api_key_prefix(raw_key.as_str());
|
|
|
|
assert!(raw_key.starts_with(EXTERNAL_API_KEY_SECRET_PREFIX));
|
|
assert_eq!(
|
|
key_prefix.chars().count(),
|
|
EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS
|
|
);
|
|
assert!(raw_key.starts_with(key_prefix.as_str()));
|
|
assert_ne!(hash_external_api_key(raw_key.as_str()), raw_key);
|
|
}
|
|
|
|
#[test]
|
|
fn default_external_api_key_scopes_cover_editor_openapi_v1() {
|
|
let scopes = default_external_api_key_scopes();
|
|
|
|
assert_eq!(
|
|
scopes,
|
|
vec![
|
|
"editor:project".to_string(),
|
|
"editor:canvas".to_string(),
|
|
"editor:image-generate".to_string(),
|
|
"editor:asset".to_string(),
|
|
]
|
|
);
|
|
}
|
|
}
|