feat: add oss direct upload adapter
This commit is contained in:
728
server-rs/crates/platform-oss/src/lib.rs
Normal file
728
server-rs/crates/platform-oss/src/lib.rs
Normal file
@@ -0,0 +1,728 @@
|
||||
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<Sha1>;
|
||||
|
||||
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<String>,
|
||||
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<String>,
|
||||
pub file_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub access: OssObjectAccess,
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
pub max_size_bytes: Option<u64>,
|
||||
pub expire_seconds: Option<u64>,
|
||||
pub success_action_status: Option<u16>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
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<String>,
|
||||
#[serde(flatten)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[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<Self> {
|
||||
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<String>,
|
||||
default_post_expire_seconds: u64,
|
||||
default_post_max_size_bytes: u64,
|
||||
default_success_action_status: u16,
|
||||
) -> Result<Self, OssError> {
|
||||
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<OssPostObjectResponse, OssError> {
|
||||
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::<Vec<_>>();
|
||||
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<String, String>,
|
||||
) -> 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<String, String>,
|
||||
) -> Result<BTreeMap<String, String>, 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::<usize>();
|
||||
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
|
||||
collapse_dashes(&normalized)
|
||||
}
|
||||
|
||||
fn sanitize_file_name(raw: &str) -> Result<String, OssError> {
|
||||
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::<String>();
|
||||
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::<String>()
|
||||
})
|
||||
.filter(|extension| !extension.is_empty());
|
||||
|
||||
Ok(match extension {
|
||||
Some(extension) => format!("{stem}.{extension}"),
|
||||
None => stem,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_value(value: String, message: &str) -> Result<String, OssError> {
|
||||
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<String>) -> Option<String> {
|
||||
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<String>) -> Option<String> {
|
||||
normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn normalize_endpoint(raw: &str) -> Result<String, OssError> {
|
||||
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<String, OssError> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user