use std::{collections::BTreeMap, error::Error, fmt}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha1::Sha1; use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; type HmacSha1 = Hmac; pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60; pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024; pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200; pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024; pub const LEGACY_PUBLIC_PREFIXES: [&str; 6] = [ "generated-character-drafts", "generated-characters", "generated-animations", "generated-custom-world-scenes", "generated-custom-world-covers", "generated-qwen-sprites", ]; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OssObjectAccess { Public, Private, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LegacyAssetPrefix { CharacterDrafts, Characters, Animations, CustomWorldScenes, CustomWorldCovers, QwenSprites, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssConfig { bucket: String, endpoint: String, access_key_id: String, access_key_secret: String, public_base_url: Option, default_post_expire_seconds: u64, default_post_max_size_bytes: u64, default_success_action_status: u16, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssPostObjectRequest { pub prefix: LegacyAssetPrefix, pub path_segments: Vec, pub file_name: String, pub content_type: Option, pub access: OssObjectAccess, pub metadata: BTreeMap, pub max_size_bytes: Option, pub expire_seconds: Option, pub success_action_status: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct OssPostObjectResponse { #[serde(rename = "signatureVersion")] pub signature_version: &'static str, pub provider: &'static str, pub bucket: String, pub endpoint: String, pub host: String, #[serde(rename = "objectKey")] pub object_key: String, #[serde(rename = "legacyPublicPath")] pub legacy_public_path: String, #[serde(rename = "publicUrl", skip_serializing_if = "Option::is_none")] pub public_url: Option, #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")] pub content_type: Option, pub access: OssObjectAccess, #[serde(rename = "keyPrefix")] pub key_prefix: String, #[serde(rename = "expiresAt")] pub expires_at: String, #[serde(rename = "maxSizeBytes")] pub max_size_bytes: u64, #[serde(rename = "successActionStatus")] pub success_action_status: u16, #[serde(rename = "formFields")] pub form_fields: OssPostObjectFormFields, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct OssPostObjectFormFields { pub key: String, pub policy: String, #[serde(rename = "OSSAccessKeyId")] pub oss_access_key_id: String, #[serde(rename = "Signature")] pub signature: String, #[serde(rename = "success_action_status")] pub success_action_status: String, #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")] pub content_type: Option, #[serde(flatten)] pub metadata: BTreeMap, } #[derive(Clone, Debug)] pub struct OssClient { config: OssConfig, } #[derive(Debug, PartialEq, Eq)] pub enum OssError { InvalidConfig(String), InvalidRequest(String), SerializePolicy(String), Sign(String), } impl LegacyAssetPrefix { pub fn parse(raw: &str) -> Option { let normalized = raw .trim() .trim_start_matches('/') .trim_end_matches('/') .trim_end_matches('*') .trim_end_matches('/'); match normalized { "generated-character-drafts" => Some(Self::CharacterDrafts), "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-covers" => Some(Self::CustomWorldCovers), "generated-qwen-sprites" => Some(Self::QwenSprites), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::CharacterDrafts => "generated-character-drafts", Self::Characters => "generated-characters", Self::Animations => "generated-animations", Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldCovers => "generated-custom-world-covers", Self::QwenSprites => "generated-qwen-sprites", } } pub fn as_public_path_prefix(&self) -> String { format!("/{}", self.as_str()) } } impl OssConfig { #[allow(clippy::too_many_arguments)] pub fn new( bucket: String, endpoint: String, access_key_id: String, access_key_secret: String, public_base_url: Option, default_post_expire_seconds: u64, default_post_max_size_bytes: u64, default_success_action_status: u16, ) -> Result { let bucket = normalize_required_value(bucket, "OSS bucket 不能为空")?; let endpoint = normalize_endpoint(&endpoint)?; let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?; let access_key_secret = normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?; let public_base_url = normalize_optional_base_url(public_base_url); if default_post_expire_seconds == 0 { return Err(OssError::InvalidConfig( "OSS PostObject 签名有效期必须大于 0".to_string(), )); } if default_post_max_size_bytes == 0 { return Err(OssError::InvalidConfig( "OSS PostObject 最大上传大小必须大于 0".to_string(), )); } if !(100..=999).contains(&default_success_action_status) { return Err(OssError::InvalidConfig( "OSS success_action_status 必须是三位 HTTP 状态码".to_string(), )); } Ok(Self { bucket, endpoint, access_key_id, access_key_secret, public_base_url, default_post_expire_seconds, default_post_max_size_bytes, default_success_action_status, }) } pub fn upload_host(&self) -> String { format!("https://{}.{}", self.bucket, self.endpoint) } pub fn endpoint(&self) -> &str { &self.endpoint } pub fn bucket(&self) -> &str { &self.bucket } } impl OssClient { pub fn new(config: OssConfig) -> Self { Self { config } } pub fn sign_post_object( &self, request: OssPostObjectRequest, ) -> Result { let max_size_bytes = request .max_size_bytes .unwrap_or(self.config.default_post_max_size_bytes); let expire_seconds = request .expire_seconds .unwrap_or(self.config.default_post_expire_seconds); let success_action_status = request .success_action_status .unwrap_or(self.config.default_success_action_status); if max_size_bytes == 0 { return Err(OssError::InvalidRequest( "maxSizeBytes 必须大于 0".to_string(), )); } if expire_seconds == 0 { return Err(OssError::InvalidRequest( "expireSeconds 必须大于 0".to_string(), )); } if !(100..=999).contains(&success_action_status) { return Err(OssError::InvalidRequest( "successActionStatus 必须是三位 HTTP 状态码".to_string(), )); } let sanitized_segments = request .path_segments .iter() .map(|segment| sanitize_path_segment(segment)) .filter(|segment| !segment.is_empty()) .collect::>(); let file_name = sanitize_file_name(&request.file_name)?; let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); let legacy_public_path = format!("/{}", object_key); let content_type = normalize_optional_value(request.content_type); let metadata = normalize_metadata(request.metadata)?; let expires_at = OffsetDateTime::now_utc() .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), )?)) .ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?; let expires_at = expires_at .format(&Rfc3339) .map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?; let policy_json = build_policy_json( &self.config.bucket, &object_key, &expires_at, max_size_bytes, success_action_status, content_type.as_deref(), &metadata, ); let policy = serde_json::to_string(&policy_json) .map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?; let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes()); let signature = sign_policy(&self.config.access_key_secret, &encoded_policy)?; let public_url = match request.access { OssObjectAccess::Public => Some(self.public_url_for(&object_key)), OssObjectAccess::Private => None, }; Ok(OssPostObjectResponse { signature_version: "v1", provider: "aliyun-oss", bucket: self.config.bucket.clone(), endpoint: self.config.endpoint.clone(), host: self.config.upload_host(), object_key: object_key.clone(), legacy_public_path, public_url, content_type: content_type.clone(), access: request.access, key_prefix: build_key_prefix(request.prefix, &sanitized_segments), expires_at, max_size_bytes, success_action_status, form_fields: OssPostObjectFormFields { key: object_key, policy: encoded_policy, oss_access_key_id: self.config.access_key_id.clone(), signature, success_action_status: success_action_status.to_string(), content_type, metadata, }, }) } fn public_url_for(&self, object_key: &str) -> String { let base_url = self .config .public_base_url .clone() .unwrap_or_else(|| self.config.upload_host()); format!("{}/{}", base_url.trim_end_matches('/'), object_key) } } impl fmt::Display for OssError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidConfig(message) | Self::InvalidRequest(message) | Self::SerializePolicy(message) | Self::Sign(message) => f.write_str(message), } } } impl Error for OssError {} fn build_policy_json( bucket: &str, object_key: &str, expires_at: &str, max_size_bytes: u64, success_action_status: u16, content_type: Option<&str>, metadata: &BTreeMap, ) -> Value { let mut conditions = vec![ json!({ "bucket": bucket }), json!(["eq", "$key", object_key]), json!(["content-length-range", 1, max_size_bytes]), json!([ "eq", "$success_action_status", success_action_status.to_string() ]), ]; if let Some(content_type) = content_type { conditions.push(json!(["eq", "$content-type", content_type])); } for (key, value) in metadata { conditions.push(json!(["eq", format!("${key}"), value])); } json!({ "expiration": expires_at, "conditions": conditions, }) } fn build_object_key( prefix: LegacyAssetPrefix, path_segments: &[String], file_name: &str, ) -> String { let mut parts = Vec::with_capacity(path_segments.len() + 2); parts.push(prefix.as_str().to_string()); parts.extend(path_segments.iter().cloned()); parts.push(file_name.to_string()); parts.join("/") } fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String { let mut parts = Vec::with_capacity(path_segments.len() + 1); parts.push(prefix.as_str().to_string()); parts.extend(path_segments.iter().cloned()); parts.join("/") } fn normalize_metadata( metadata: BTreeMap, ) -> Result, OssError> { let mut normalized = BTreeMap::new(); for (key, value) in metadata { let key = key.trim(); let value = value.trim(); if key.is_empty() || value.is_empty() { continue; } let key = normalize_metadata_key(key); normalized.insert(key, value.to_string()); } let total_bytes = normalized .iter() .map(|(key, value)| key.len() + value.len()) .sum::(); if total_bytes > DEFAULT_METADATA_TOTAL_BYTES_LIMIT { return Err(OssError::InvalidRequest(format!( "x-oss-meta-* 总大小不能超过 {} 字节", DEFAULT_METADATA_TOTAL_BYTES_LIMIT ))); } Ok(normalized) } fn normalize_metadata_key(raw: &str) -> String { let stripped = raw .trim() .trim_start_matches("x-oss-meta-") .trim() .to_ascii_lowercase(); let sanitized = stripped .chars() .map(|character| match character { 'a'..='z' | '0'..='9' | '-' => character, '_' | ' ' | '/' | '.' => '-', _ => '-', }) .collect::(); let sanitized = collapse_dashes(&sanitized); format!( "x-oss-meta-{}", if sanitized.is_empty() { "metadata".to_string() } else { sanitized } ) } fn sanitize_path_segment(raw: &str) -> String { let normalized = raw .trim() .to_ascii_lowercase() .chars() .map(|character| match character { 'a'..='z' | '0'..='9' | '-' | '_' => character, _ => '-', }) .collect::(); collapse_dashes(&normalized) } fn sanitize_file_name(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(OssError::InvalidRequest("fileName 不能为空".to_string())); } let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed).trim(); if file_name.is_empty() { return Err(OssError::InvalidRequest("fileName 不能为空".to_string())); } let (raw_stem, raw_extension) = match file_name.rsplit_once('.') { Some((stem, extension)) if !stem.trim().is_empty() && !extension.trim().is_empty() => { (stem, Some(extension)) } _ => (file_name, None), }; let stem = raw_stem .trim() .to_ascii_lowercase() .chars() .map(|character| match character { 'a'..='z' | '0'..='9' | '-' | '_' => character, _ => '-', }) .collect::(); let stem = collapse_dashes(&stem); if stem.is_empty() { return Err(OssError::InvalidRequest("fileName 主体不合法".to_string())); } let extension = raw_extension .map(|extension| { extension .trim() .to_ascii_lowercase() .chars() .filter(|character| character.is_ascii_alphanumeric()) .collect::() }) .filter(|extension| !extension.is_empty()); Ok(match extension { Some(extension) => format!("{stem}.{extension}"), None => stem, }) } fn normalize_required_value(value: String, message: &str) -> Result { let value = value.trim().to_string(); if value.is_empty() { return Err(OssError::InvalidConfig(message.to_string())); } Ok(value) } fn normalize_optional_value(value: Option) -> Option { value.and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) } fn normalize_optional_base_url(value: Option) -> Option { normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string()) } fn normalize_endpoint(raw: &str) -> Result { let endpoint = raw .trim() .trim_start_matches("https://") .trim_start_matches("http://") .trim_matches('/') .to_string(); if endpoint.is_empty() { return Err(OssError::InvalidConfig("OSS endpoint 不能为空".to_string())); } Ok(endpoint) } fn collapse_dashes(value: &str) -> String { value .chars() .fold( (String::new(), false), |(mut output, last_is_dash), character| { let is_dash = character == '-'; if is_dash && last_is_dash { return (output, true); } output.push(character); (output, is_dash) }, ) .0 .trim_matches('-') .to_string() } fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result { let mut signer = HmacSha1::new_from_slice(access_key_secret.as_bytes()) .map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA1 失败:{error}")))?; signer.update(encoded_policy.as_bytes()); Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes())) } #[cfg(test)] mod tests { use super::*; fn build_client() -> OssClient { OssClient::new( OssConfig::new( "genarrative-assets".to_string(), "oss-cn-shanghai.aliyuncs.com".to_string(), "test-access-key-id".to_string(), "test-access-key-secret".to_string(), Some("https://cdn.genarrative.local".to_string()), DEFAULT_POST_EXPIRE_SECONDS, DEFAULT_POST_MAX_SIZE_BYTES, DEFAULT_SUCCESS_ACTION_STATUS, ) .expect("OSS config should be valid"), ) } #[test] fn parse_legacy_prefix_accepts_public_style_path() { assert_eq!( LegacyAssetPrefix::parse("/generated-characters/*"), Some(LegacyAssetPrefix::Characters) ); assert_eq!(LegacyAssetPrefix::parse("unknown"), None); } #[test] fn sign_post_object_returns_legacy_compatible_key_and_urls() { let client = build_client(); let mut metadata = BTreeMap::new(); metadata.insert("asset-kind".to_string(), "character-visual".to_string()); metadata.insert("origin".to_string(), "browser-upload".to_string()); let response = client .sign_post_object(OssPostObjectRequest { prefix: LegacyAssetPrefix::Characters, path_segments: vec![ "Hero_001".to_string(), "Visual".to_string(), "Asset_01".to_string(), ], file_name: "Master.PNG".to_string(), content_type: Some("image/png".to_string()), access: OssObjectAccess::Public, metadata, max_size_bytes: Some(5 * 1024 * 1024), expire_seconds: Some(300), success_action_status: Some(200), }) .expect("post object signature should build"); assert_eq!( response.object_key, "generated-characters/hero_001/visual/asset_01/master.png" ); assert_eq!( response.legacy_public_path, "/generated-characters/hero_001/visual/asset_01/master.png" ); assert_eq!( response.public_url.as_deref(), Some( "https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png" ) ); assert_eq!( response.form_fields.oss_access_key_id, "test-access-key-id".to_string() ); assert_eq!( response.form_fields.metadata.get("x-oss-meta-asset-kind"), Some(&"character-visual".to_string()) ); } #[test] fn sign_post_object_embeds_policy_constraints() { let client = build_client(); let response = client .sign_post_object(OssPostObjectRequest { prefix: LegacyAssetPrefix::QwenSprites, path_segments: vec!["_drafts".to_string(), "master".to_string()], file_name: "candidate-01.png".to_string(), content_type: Some("image/png".to_string()), access: OssObjectAccess::Private, metadata: BTreeMap::new(), max_size_bytes: Some(1024), expire_seconds: Some(60), success_action_status: Some(200), }) .expect("post object signature should build"); let decoded_policy = BASE64_STANDARD .decode(response.form_fields.policy.as_bytes()) .expect("policy should be valid base64"); let policy: Value = serde_json::from_slice(&decoded_policy).expect("policy should be valid json"); assert_eq!( policy["conditions"][0]["bucket"], Value::String("genarrative-assets".to_string()) ); assert_eq!( policy["conditions"][1], json!([ "eq", "$key", "generated-qwen-sprites/_drafts/master/candidate-01.png" ]) ); assert_eq!( policy["conditions"][2], json!(["content-length-range", 1, 1024]) ); assert_eq!( policy["conditions"][3], json!(["eq", "$success_action_status", "200"]) ); assert_eq!( policy["conditions"][4], json!(["eq", "$content-type", "image/png"]) ); assert!(response.public_url.is_none()); } #[test] fn sanitize_file_name_rejects_empty_input() { let error = sanitize_file_name(" ").expect_err("empty file name should fail"); assert_eq!( error, OssError::InvalidRequest("fileName 不能为空".to_string()) ); } }