use axum::{ body::Body, extract::{Path, State}, http::{HeaderName, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest}; use serde_json::json; use crate::{http_error::AppError, state::AppState}; const CACHE_CONTROL_VALUE: &str = "private, max-age=60"; const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key"; pub async fn proxy_generated_character_drafts( State(state): State, Path(path): Path, ) -> Response { proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await } pub async fn proxy_generated_characters( State(state): State, Path(path): Path, ) -> Response { proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await } pub async fn proxy_generated_animations( State(state): State, Path(path): Path, ) -> Response { proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await } pub async fn proxy_generated_custom_world_scenes( State(state): State, Path(path): Path, ) -> Response { proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await } pub async fn proxy_generated_custom_world_covers( State(state): State, Path(path): Path, ) -> Response { proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await } pub async fn proxy_generated_qwen_sprites( State(state): State, Path(path): Path, ) -> Response { proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await } async fn proxy_legacy_generated_asset( state: AppState, prefix: LegacyAssetPrefix, path: String, ) -> Response { match read_legacy_generated_asset(&state, prefix, path).await { Ok(response) => response, Err(error) => error.into_response(), } } async fn read_legacy_generated_asset( state: &AppState, prefix: LegacyAssetPrefix, path: String, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let object_key = build_generated_object_key(prefix, path.as_str())?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: object_key.clone(), expire_seconds: Some(60), }) .map_err(map_legacy_generated_oss_error)?; let upstream_response = reqwest::Client::new() .get(signed.signed_url) .send() .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取 OSS 旧 generated 资源失败:{error}"), })) })?; if upstream_response.status() == reqwest::StatusCode::NOT_FOUND { return Err( AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "aliyun-oss", "objectKey": object_key, })), ); } let status = upstream_response.status(); let content_type = upstream_response .headers() .get(header::CONTENT_TYPE) .cloned(); let bytes = upstream_response .error_for_status() .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取 OSS 旧 generated 资源失败:{error}"), })) })? .bytes() .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取 OSS 旧 generated 资源内容失败:{error}"), })) })?; let mut response = Response::builder() .status(status) .header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE) .header( HeaderName::from_static(ASSET_OBJECT_KEY_HEADER), HeaderValue::from_str(object_key.as_str()).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "legacy-generated-assets", "message": format!("构造资源响应头失败:{error}"), })) })?, ); if let Some(content_type) = content_type { response = response.header(header::CONTENT_TYPE, content_type); } response.body(Body::from(bytes)).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "legacy-generated-assets", "message": format!("构造资源响应失败:{error}"), })) }) } fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result { let path = path.trim().trim_matches('/'); if path.is_empty() || path.split('/').any(is_invalid_path_segment) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "legacy-generated-assets", "message": "generated 资源路径不合法。", })), ); } Ok(format!("{}/{}", prefix.as_str(), path)) } fn is_invalid_path_segment(segment: &str) -> bool { segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\') } fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError { let status = match error { platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => { StatusCode::BAD_REQUEST } platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND, platform_oss::OssError::Request(_) | platform_oss::OssError::SerializePolicy(_) | platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })) } #[cfg(test)] mod tests { use super::*; #[test] fn build_generated_object_key_keeps_supported_prefix() { let object_key = build_generated_object_key( LegacyAssetPrefix::Animations, "hero/animation-set-1/idle/frame01.png", ) .expect("object key should build"); assert_eq!( object_key, "generated-animations/hero/animation-set-1/idle/frame01.png" ); } #[test] fn build_generated_object_key_rejects_parent_segment() { assert!( build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err() ); } }