1645 lines
54 KiB
Rust
1645 lines
54 KiB
Rust
use std::{collections::BTreeMap, error::Error, fmt};
|
||
|
||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||
use hmac::{Hmac, Mac};
|
||
use reqwest::Method;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use sha2::{Digest, Sha256};
|
||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||
|
||
type HmacSha256 = Hmac<Sha256>;
|
||
|
||
pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60;
|
||
pub const DEFAULT_READ_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;
|
||
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
|
||
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
||
const OSS_V4_SERVICE: &str = "oss";
|
||
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||
|
||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [
|
||
"generated-character-drafts",
|
||
"generated-characters",
|
||
"generated-animations",
|
||
"generated-big-fish-assets",
|
||
"generated-square-hole-assets",
|
||
"generated-match3d-assets",
|
||
"generated-puzzle-assets",
|
||
"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,
|
||
BigFishAssets,
|
||
SquareHoleAssets,
|
||
Match3DAssets,
|
||
PuzzleAssets,
|
||
CustomWorldScenes,
|
||
CustomWorldCovers,
|
||
QwenSprites,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct OssConfig {
|
||
bucket: String,
|
||
endpoint: String,
|
||
access_key_id: String,
|
||
access_key_secret: String,
|
||
default_read_expire_seconds: u64,
|
||
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)]
|
||
pub struct OssSignedGetObjectUrlRequest {
|
||
pub object_key: String,
|
||
pub expire_seconds: Option<u64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct OssHeadObjectRequest {
|
||
pub object_key: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct OssPutObjectRequest {
|
||
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 body: Vec<u8>,
|
||
}
|
||
|
||
#[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 = "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 OssSignedGetObjectUrlResponse {
|
||
pub provider: &'static str,
|
||
pub bucket: String,
|
||
pub endpoint: String,
|
||
pub host: String,
|
||
#[serde(rename = "objectKey")]
|
||
pub object_key: String,
|
||
#[serde(rename = "expiresAt")]
|
||
pub expires_at: String,
|
||
#[serde(rename = "signedUrl")]
|
||
pub signed_url: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct OssHeadObjectResponse {
|
||
pub bucket: String,
|
||
pub object_key: String,
|
||
pub content_length: u64,
|
||
pub content_type: Option<String>,
|
||
pub etag: Option<String>,
|
||
pub last_modified: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||
pub struct OssPutObjectResponse {
|
||
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 = "contentType", skip_serializing_if = "Option::is_none")]
|
||
pub content_type: Option<String>,
|
||
#[serde(rename = "contentLength")]
|
||
pub content_length: u64,
|
||
pub access: OssObjectAccess,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub etag: Option<String>,
|
||
#[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")]
|
||
pub last_modified: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||
pub struct OssPostObjectFormFields {
|
||
pub key: String,
|
||
pub policy: String,
|
||
#[serde(rename = "x-oss-signature-version")]
|
||
pub signature_version: String,
|
||
#[serde(rename = "x-oss-credential")]
|
||
pub credential: String,
|
||
#[serde(rename = "x-oss-date")]
|
||
pub date: String,
|
||
#[serde(rename = "x-oss-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),
|
||
ObjectNotFound(String),
|
||
Request(String),
|
||
SerializePolicy(String),
|
||
Sign(String),
|
||
}
|
||
|
||
// 平台 OSS 错误只先归类,不在 platform 层绑定 HTTP status。
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum OssErrorKind {
|
||
InvalidConfig,
|
||
InvalidRequest,
|
||
ObjectNotFound,
|
||
Request,
|
||
SerializePolicy,
|
||
Sign,
|
||
}
|
||
|
||
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-big-fish-assets" => Some(Self::BigFishAssets),
|
||
"generated-square-hole-assets" => Some(Self::SquareHoleAssets),
|
||
"generated-match3d-assets" => Some(Self::Match3DAssets),
|
||
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
||
"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::BigFishAssets => "generated-big-fish-assets",
|
||
Self::SquareHoleAssets => "generated-square-hole-assets",
|
||
Self::Match3DAssets => "generated-match3d-assets",
|
||
Self::PuzzleAssets => "generated-puzzle-assets",
|
||
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())
|
||
}
|
||
|
||
pub fn from_object_key(raw: &str) -> Option<Self> {
|
||
let normalized = raw.trim().trim_start_matches('/').trim();
|
||
let prefix = normalized.split('/').next()?;
|
||
Self::parse(prefix)
|
||
}
|
||
}
|
||
|
||
impl OssConfig {
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn new(
|
||
bucket: String,
|
||
endpoint: String,
|
||
access_key_id: String,
|
||
access_key_secret: String,
|
||
default_read_expire_seconds: u64,
|
||
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 不能为空")?;
|
||
|
||
if default_read_expire_seconds == 0 {
|
||
return Err(OssError::InvalidConfig(
|
||
"OSS 私有读签名有效期必须大于 0".to_string(),
|
||
));
|
||
}
|
||
|
||
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,
|
||
default_read_expire_seconds,
|
||
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
|
||
}
|
||
|
||
pub fn access_key_id(&self) -> &str {
|
||
&self.access_key_id
|
||
}
|
||
|
||
pub fn access_key_secret(&self) -> &str {
|
||
&self.access_key_secret
|
||
}
|
||
}
|
||
|
||
impl OssClient {
|
||
pub fn new(config: OssConfig) -> Self {
|
||
Self { config }
|
||
}
|
||
|
||
pub fn config_bucket(&self) -> &str {
|
||
self.config.bucket()
|
||
}
|
||
|
||
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 signed_at = OffsetDateTime::now_utc();
|
||
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
|
||
let signature_date = build_v4_signature_date(signed_at)?;
|
||
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
|
||
let policy_json = build_policy_json(
|
||
&self.config.bucket,
|
||
&object_key,
|
||
&expires_at,
|
||
max_size_bytes,
|
||
success_action_status,
|
||
content_type.as_deref(),
|
||
&metadata,
|
||
&credential,
|
||
&signature_date,
|
||
);
|
||
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_v4_content(
|
||
&self.config.access_key_secret,
|
||
&signature_scope,
|
||
&encoded_policy,
|
||
)?;
|
||
|
||
Ok(OssPostObjectResponse {
|
||
signature_version: "v4",
|
||
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,
|
||
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,
|
||
signature_version: OSS_V4_ALGORITHM.to_string(),
|
||
credential,
|
||
date: signature_date,
|
||
signature,
|
||
success_action_status: success_action_status.to_string(),
|
||
content_type,
|
||
metadata,
|
||
},
|
||
})
|
||
}
|
||
|
||
// 私有 bucket 的对象读取统一走短期签名 URL,避免把长期主凭证下发给浏览器。
|
||
pub fn sign_get_object_url(
|
||
&self,
|
||
request: OssSignedGetObjectUrlRequest,
|
||
) -> Result<OssSignedGetObjectUrlResponse, OssError> {
|
||
let expire_seconds = request
|
||
.expire_seconds
|
||
.unwrap_or(self.config.default_read_expire_seconds);
|
||
|
||
if expire_seconds == 0 {
|
||
return Err(OssError::InvalidRequest(
|
||
"expireSeconds 必须大于 0".to_string(),
|
||
));
|
||
}
|
||
|
||
let object_key = normalize_object_key(&request.object_key)?;
|
||
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_text = expires_at
|
||
.format(&Rfc3339)
|
||
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
|
||
|
||
let signed_at = OffsetDateTime::now_utc();
|
||
let signed_at_text = build_v4_signature_date(signed_at)?;
|
||
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
|
||
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
|
||
let mut query = BTreeMap::from([
|
||
("x-oss-additional-headers".to_string(), "host".to_string()),
|
||
(
|
||
"x-oss-signature-version".to_string(),
|
||
OSS_V4_ALGORITHM.to_string(),
|
||
),
|
||
("x-oss-credential".to_string(), credential),
|
||
("x-oss-date".to_string(), signed_at_text),
|
||
("x-oss-expires".to_string(), expire_seconds.to_string()),
|
||
]);
|
||
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
|
||
let object_url_path = format!("/{}", encode_url_path(&object_key));
|
||
let additional_headers = "host";
|
||
let canonical_headers =
|
||
format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
|
||
let canonical_query = build_canonical_query_string(&query);
|
||
let canonical_request = build_v4_canonical_request(
|
||
Method::GET.as_str(),
|
||
&canonical_uri,
|
||
&canonical_query,
|
||
&canonical_headers,
|
||
additional_headers,
|
||
OSS_UNSIGNED_PAYLOAD,
|
||
);
|
||
let string_to_sign = build_v4_string_to_sign(
|
||
query["x-oss-date"].as_str(),
|
||
&signature_scope,
|
||
&canonical_request,
|
||
);
|
||
let signature = sign_v4_content(
|
||
&self.config.access_key_secret,
|
||
&signature_scope,
|
||
&string_to_sign,
|
||
)?;
|
||
query.insert("x-oss-signature".to_string(), signature);
|
||
let signed_url = format!(
|
||
"{}{}?{}",
|
||
self.config.upload_host(),
|
||
object_url_path,
|
||
build_canonical_query_string(&query)
|
||
);
|
||
|
||
Ok(OssSignedGetObjectUrlResponse {
|
||
provider: "aliyun-oss",
|
||
bucket: self.config.bucket.clone(),
|
||
endpoint: self.config.endpoint.clone(),
|
||
host: self.config.upload_host(),
|
||
object_key,
|
||
expires_at: expires_at_text,
|
||
signed_url,
|
||
})
|
||
}
|
||
|
||
// 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。
|
||
pub async fn head_object(
|
||
&self,
|
||
client: &reqwest::Client,
|
||
request: OssHeadObjectRequest,
|
||
) -> Result<OssHeadObjectResponse, OssError> {
|
||
let object_key = normalize_object_key(&request.object_key)?;
|
||
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
|
||
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
|
||
let response = send_signed_request(
|
||
client,
|
||
&self.config,
|
||
Method::HEAD,
|
||
Some(&object_key),
|
||
target_url,
|
||
)
|
||
.await?;
|
||
|
||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||
return Err(OssError::ObjectNotFound(format!(
|
||
"OSS 对象不存在:{}",
|
||
request.object_key
|
||
)));
|
||
}
|
||
|
||
if !response.status().is_success() {
|
||
return Err(OssError::Request(format!(
|
||
"OSS HEAD Object 失败,状态码:{}",
|
||
response.status()
|
||
)));
|
||
}
|
||
|
||
let headers = response.headers();
|
||
let content_length = headers
|
||
.get(reqwest::header::CONTENT_LENGTH)
|
||
.and_then(|value| value.to_str().ok())
|
||
.and_then(|value| value.parse::<u64>().ok())
|
||
.unwrap_or(0);
|
||
let content_type = headers
|
||
.get(reqwest::header::CONTENT_TYPE)
|
||
.and_then(|value| value.to_str().ok())
|
||
.map(|value| value.to_string());
|
||
let etag = headers
|
||
.get(reqwest::header::ETAG)
|
||
.and_then(|value| value.to_str().ok())
|
||
.map(|value| value.trim_matches('"').to_string());
|
||
let last_modified = headers
|
||
.get(reqwest::header::LAST_MODIFIED)
|
||
.and_then(|value| value.to_str().ok())
|
||
.map(|value| value.to_string());
|
||
|
||
Ok(OssHeadObjectResponse {
|
||
bucket: self.config.bucket.clone(),
|
||
object_key,
|
||
content_length,
|
||
content_type,
|
||
etag,
|
||
last_modified,
|
||
})
|
||
}
|
||
|
||
// AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。
|
||
pub async fn put_object(
|
||
&self,
|
||
client: &reqwest::Client,
|
||
request: OssPutObjectRequest,
|
||
) -> Result<OssPutObjectResponse, OssError> {
|
||
if request.body.is_empty() {
|
||
return Err(OssError::InvalidRequest(
|
||
"服务端上传对象内容不能为空".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 content_type = normalize_optional_value(request.content_type);
|
||
let metadata = normalize_metadata(request.metadata)?;
|
||
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
|
||
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
|
||
let content_length = u64::try_from(request.body.len())
|
||
.map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
|
||
let builder = signed_request_builder(
|
||
client,
|
||
&self.config,
|
||
Method::PUT,
|
||
Some(&object_key),
|
||
target_url,
|
||
content_type.as_deref(),
|
||
&metadata,
|
||
)?
|
||
.header(reqwest::header::CONTENT_LENGTH, content_length)
|
||
.body(request.body);
|
||
|
||
let response = builder
|
||
.send()
|
||
.await
|
||
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
|
||
|
||
if !response.status().is_success() {
|
||
return Err(OssError::Request(format!(
|
||
"OSS PutObject 失败,状态码:{}",
|
||
response.status()
|
||
)));
|
||
}
|
||
|
||
let headers = response.headers();
|
||
let etag = headers
|
||
.get(reqwest::header::ETAG)
|
||
.and_then(|value| value.to_str().ok())
|
||
.map(|value| value.trim_matches('"').to_string());
|
||
let last_modified = headers
|
||
.get(reqwest::header::LAST_MODIFIED)
|
||
.and_then(|value| value.to_str().ok())
|
||
.map(|value| value.to_string());
|
||
|
||
Ok(OssPutObjectResponse {
|
||
provider: "aliyun-oss",
|
||
bucket: self.config.bucket.clone(),
|
||
endpoint: self.config.endpoint.clone(),
|
||
host: self.config.upload_host(),
|
||
legacy_public_path: format!("/{object_key}"),
|
||
object_key,
|
||
content_type,
|
||
content_length,
|
||
access: request.access,
|
||
etag,
|
||
last_modified,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for OssError {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
Self::InvalidConfig(message)
|
||
| Self::InvalidRequest(message)
|
||
| Self::ObjectNotFound(message)
|
||
| Self::Request(message)
|
||
| Self::SerializePolicy(message)
|
||
| Self::Sign(message) => f.write_str(message),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Error for OssError {}
|
||
|
||
impl OssError {
|
||
pub fn kind(&self) -> OssErrorKind {
|
||
match self {
|
||
Self::InvalidConfig(_) => OssErrorKind::InvalidConfig,
|
||
Self::InvalidRequest(_) => OssErrorKind::InvalidRequest,
|
||
Self::ObjectNotFound(_) => OssErrorKind::ObjectNotFound,
|
||
Self::Request(_) => OssErrorKind::Request,
|
||
Self::SerializePolicy(_) => OssErrorKind::SerializePolicy,
|
||
Self::Sign(_) => OssErrorKind::Sign,
|
||
}
|
||
}
|
||
}
|
||
|
||
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>,
|
||
credential: &str,
|
||
signature_date: &str,
|
||
) -> 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()
|
||
]),
|
||
json!(["eq", "$x-oss-signature-version", OSS_V4_ALGORITHM]),
|
||
json!(["eq", "$x-oss-credential", credential]),
|
||
json!(["eq", "$x-oss-date", signature_date]),
|
||
];
|
||
|
||
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_url(
|
||
bucket: &str,
|
||
endpoint: &str,
|
||
object_key: &str,
|
||
) -> Result<reqwest::Url, String> {
|
||
let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))
|
||
.map_err(|error| error.to_string())?;
|
||
url = url
|
||
.join(object_key.trim_start_matches('/'))
|
||
.map_err(|error| error.to_string())?;
|
||
Ok(url)
|
||
}
|
||
|
||
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 normalize_object_key(raw: &str) -> Result<String, OssError> {
|
||
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
|
||
if normalized.is_empty() {
|
||
return Err(OssError::InvalidRequest("objectKey 不能为空".to_string()));
|
||
}
|
||
|
||
if LegacyAssetPrefix::from_object_key(&normalized).is_none() {
|
||
return Err(OssError::InvalidRequest(
|
||
"objectKey 必须落在受支持的 generated-* 前缀下".to_string(),
|
||
));
|
||
}
|
||
|
||
let segments = normalized.split('/').collect::<Vec<_>>();
|
||
if segments.len() < 2 {
|
||
return Err(OssError::InvalidRequest(
|
||
"objectKey 至少需要包含前缀和文件名".to_string(),
|
||
));
|
||
}
|
||
|
||
for segment in &segments {
|
||
if segment.is_empty() || *segment == "." || *segment == ".." {
|
||
return Err(OssError::InvalidRequest(
|
||
"objectKey 包含非法路径片段".to_string(),
|
||
));
|
||
}
|
||
|
||
if segment.contains('\\') {
|
||
return Err(OssError::InvalidRequest(
|
||
"objectKey 不能包含反斜杠".to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
Ok(normalized)
|
||
}
|
||
|
||
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_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()
|
||
}
|
||
|
||
async fn send_signed_request(
|
||
client: &reqwest::Client,
|
||
config: &OssConfig,
|
||
method: Method,
|
||
object_key: Option<&str>,
|
||
target_url: reqwest::Url,
|
||
) -> Result<reqwest::Response, OssError> {
|
||
signed_request_builder(
|
||
client,
|
||
config,
|
||
method,
|
||
object_key,
|
||
target_url,
|
||
None,
|
||
&BTreeMap::new(),
|
||
)?
|
||
.send()
|
||
.await
|
||
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))
|
||
}
|
||
|
||
fn signed_request_builder(
|
||
client: &reqwest::Client,
|
||
config: &OssConfig,
|
||
method: Method,
|
||
object_key: Option<&str>,
|
||
target_url: reqwest::Url,
|
||
content_type: Option<&str>,
|
||
oss_headers: &BTreeMap<String, String>,
|
||
) -> Result<reqwest::RequestBuilder, OssError> {
|
||
let signed_at = OffsetDateTime::now_utc();
|
||
let signed_at_text = build_v4_signature_date(signed_at)?;
|
||
let signature_scope = build_v4_signature_scope(config.endpoint(), signed_at)?;
|
||
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
|
||
let canonical_uri = build_v4_canonical_uri(config.bucket(), object_path);
|
||
let body_sha256 = OSS_UNSIGNED_PAYLOAD.to_string();
|
||
let mut signed_headers = BTreeMap::from([
|
||
(
|
||
"host".to_string(),
|
||
format!("{}.{}", config.bucket(), config.endpoint()),
|
||
),
|
||
("x-oss-content-sha256".to_string(), body_sha256.clone()),
|
||
("x-oss-date".to_string(), signed_at_text.clone()),
|
||
]);
|
||
if let Some(content_type) = content_type {
|
||
signed_headers.insert("content-type".to_string(), content_type.to_string());
|
||
}
|
||
for (key, value) in oss_headers {
|
||
signed_headers.insert(key.to_ascii_lowercase(), value.trim().to_string());
|
||
}
|
||
|
||
let canonical_headers = build_v4_canonical_headers(&signed_headers);
|
||
let additional_headers = "host";
|
||
let canonical_request = build_v4_canonical_request(
|
||
method.as_str(),
|
||
&canonical_uri,
|
||
"",
|
||
&canonical_headers,
|
||
additional_headers,
|
||
&body_sha256,
|
||
);
|
||
let string_to_sign =
|
||
build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
|
||
let signature = sign_v4_content(
|
||
config.access_key_secret(),
|
||
&signature_scope,
|
||
&string_to_sign,
|
||
)?;
|
||
let mut builder = client
|
||
.request(method, target_url)
|
||
.header("x-oss-content-sha256", body_sha256)
|
||
.header("x-oss-date", signed_at_text)
|
||
.header(
|
||
"Authorization",
|
||
format!(
|
||
"{OSS_V4_ALGORITHM} Credential={}/{},AdditionalHeaders={},Signature={}",
|
||
config.access_key_id(),
|
||
signature_scope,
|
||
additional_headers,
|
||
signature
|
||
),
|
||
);
|
||
|
||
if let Some(content_type) = content_type {
|
||
builder = builder.header(reqwest::header::CONTENT_TYPE, content_type);
|
||
}
|
||
|
||
for (key, value) in oss_headers {
|
||
builder = builder.header(key.as_str(), value.as_str());
|
||
}
|
||
|
||
Ok(builder)
|
||
}
|
||
|
||
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
|
||
let date = format_v4_signature_scope_date(signed_at);
|
||
let region = extract_oss_region(endpoint)?;
|
||
|
||
Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}"))
|
||
}
|
||
|
||
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
|
||
// 中文注释:time::Time 的 Display 在小时小于 10 时不会稳定补零,OSS V4 必须使用固定宽度 UTC 时间。
|
||
Ok(format!(
|
||
"{}T{:02}{:02}{:02}Z",
|
||
format_v4_signature_scope_date(signed_at),
|
||
signed_at.hour(),
|
||
signed_at.minute(),
|
||
signed_at.second()
|
||
))
|
||
}
|
||
|
||
fn format_v4_signature_scope_date(signed_at: OffsetDateTime) -> String {
|
||
format!(
|
||
"{:04}{:02}{:02}",
|
||
signed_at.year(),
|
||
signed_at.month() as u8,
|
||
signed_at.day()
|
||
)
|
||
}
|
||
|
||
fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
|
||
match object_key.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(object_key) => format!(
|
||
"/{}/{}",
|
||
encode_url_query_value(bucket),
|
||
encode_url_path(object_key.trim_start_matches('/'))
|
||
),
|
||
None => format!("/{}/", encode_url_query_value(bucket)),
|
||
}
|
||
}
|
||
|
||
fn extract_oss_region(endpoint: &str) -> Result<String, OssError> {
|
||
endpoint
|
||
.trim()
|
||
.trim_start_matches("https://")
|
||
.trim_start_matches("http://")
|
||
.split('.')
|
||
.next()
|
||
.and_then(|segment| segment.strip_prefix("oss-"))
|
||
.map(str::to_string)
|
||
.filter(|region| !region.is_empty())
|
||
.ok_or_else(|| {
|
||
OssError::InvalidConfig(format!("OSS endpoint 无法解析 region,当前值:{endpoint}"))
|
||
})
|
||
}
|
||
|
||
fn sign_v4_content(
|
||
access_key_secret: &str,
|
||
signature_scope: &str,
|
||
content: &str,
|
||
) -> Result<String, OssError> {
|
||
let signing_key = build_v4_signing_key(access_key_secret, signature_scope)?;
|
||
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
|
||
}
|
||
|
||
fn build_v4_signing_key(
|
||
access_key_secret: &str,
|
||
signature_scope: &str,
|
||
) -> Result<Vec<u8>, OssError> {
|
||
let mut parts = signature_scope.split('/');
|
||
let date = parts
|
||
.next()
|
||
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少日期".to_string()))?;
|
||
let region = parts
|
||
.next()
|
||
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 region".to_string()))?;
|
||
let service = parts
|
||
.next()
|
||
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 service".to_string()))?;
|
||
let request = parts
|
||
.next()
|
||
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 request".to_string()))?;
|
||
|
||
let date_key = hmac_sha256_raw(format!("aliyun_v4{access_key_secret}").as_bytes(), date)?;
|
||
let region_key = hmac_sha256_raw(&date_key, region)?;
|
||
let service_key = hmac_sha256_raw(®ion_key, service)?;
|
||
hmac_sha256_raw(&service_key, request)
|
||
}
|
||
|
||
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, OssError> {
|
||
let mut signer = HmacSha256::new_from_slice(key)
|
||
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA256 失败:{error}")))?;
|
||
signer.update(content.as_bytes());
|
||
Ok(signer.finalize().into_bytes().to_vec())
|
||
}
|
||
|
||
fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String {
|
||
let mut signer = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts keys of any size");
|
||
signer.update(content);
|
||
hex_lower(&signer.finalize().into_bytes())
|
||
}
|
||
|
||
fn build_v4_canonical_request(
|
||
method: &str,
|
||
canonical_uri: &str,
|
||
canonical_query: &str,
|
||
canonical_headers: &str,
|
||
signed_headers: &str,
|
||
payload_hash: &str,
|
||
) -> String {
|
||
format!(
|
||
"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||
)
|
||
}
|
||
|
||
fn build_v4_string_to_sign(
|
||
signature_date: &str,
|
||
signature_scope: &str,
|
||
canonical_request: &str,
|
||
) -> String {
|
||
format!(
|
||
"{OSS_V4_ALGORITHM}\n{signature_date}\n{signature_scope}\n{}",
|
||
sha256_hex(canonical_request.as_bytes())
|
||
)
|
||
}
|
||
|
||
fn sha256_hex(content: &[u8]) -> String {
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(content);
|
||
hex_lower(&hasher.finalize())
|
||
}
|
||
|
||
fn hex_lower(bytes: &[u8]) -> String {
|
||
bytes
|
||
.iter()
|
||
.map(|byte| format!("{byte:02x}"))
|
||
.collect::<String>()
|
||
}
|
||
|
||
fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
|
||
headers
|
||
.iter()
|
||
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
|
||
.collect::<String>()
|
||
}
|
||
|
||
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
|
||
params
|
||
.iter()
|
||
.map(|(key, value)| {
|
||
format!(
|
||
"{}={}",
|
||
encode_url_query_value(key),
|
||
encode_url_query_value(value)
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("&")
|
||
}
|
||
|
||
fn encode_url_path(path: &str) -> String {
|
||
path.split('/')
|
||
.map(encode_url_query_value)
|
||
.collect::<Vec<_>>()
|
||
.join("/")
|
||
}
|
||
|
||
fn encode_url_query_value(value: &str) -> String {
|
||
let mut encoded = String::with_capacity(value.len());
|
||
|
||
for byte in value.bytes() {
|
||
match byte {
|
||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||
encoded.push(byte as char)
|
||
}
|
||
_ => {
|
||
use std::fmt::Write as _;
|
||
|
||
let _ = write!(&mut encoded, "%{byte:02X}");
|
||
}
|
||
}
|
||
}
|
||
|
||
encoded
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn oss_error_kind_is_stable_for_adapter_mapping() {
|
||
assert_eq!(
|
||
OssError::InvalidConfig("bad config".to_string()).kind(),
|
||
OssErrorKind::InvalidConfig
|
||
);
|
||
assert_eq!(
|
||
OssError::ObjectNotFound("missing".to_string()).kind(),
|
||
OssErrorKind::ObjectNotFound
|
||
);
|
||
assert_eq!(
|
||
OssError::Request("network".to_string()).kind(),
|
||
OssErrorKind::Request
|
||
);
|
||
}
|
||
|
||
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(),
|
||
DEFAULT_READ_EXPIRE_SECONDS,
|
||
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("/generated-puzzle-assets/*"),
|
||
Some(LegacyAssetPrefix::PuzzleAssets)
|
||
);
|
||
assert_eq!(
|
||
LegacyAssetPrefix::parse("/generated-match3d-assets/*"),
|
||
Some(LegacyAssetPrefix::Match3DAssets)
|
||
);
|
||
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets"));
|
||
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets"));
|
||
assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn build_v4_signature_date_zero_pads_single_digit_time_parts() {
|
||
let signed_at =
|
||
OffsetDateTime::from_unix_timestamp(1_771_477_389).expect("timestamp should be valid");
|
||
|
||
assert_eq!(
|
||
build_v4_signature_date(signed_at).expect("date should format"),
|
||
"20260219T050309Z"
|
||
);
|
||
assert_eq!(
|
||
build_v4_signature_scope("oss-cn-shanghai.aliyuncs.com", signed_at)
|
||
.expect("scope should format"),
|
||
"20260219/cn-shanghai/oss/aliyun_v4_request"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sign_post_object_returns_bucket_and_object_key_for_private_storage_truth() {
|
||
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.bucket, "genarrative-assets".to_string());
|
||
assert_eq!(
|
||
response.form_fields.signature_version,
|
||
OSS_V4_ALGORITHM.to_string()
|
||
);
|
||
assert!(
|
||
response
|
||
.form_fields
|
||
.credential
|
||
.starts_with("test-access-key-id/")
|
||
);
|
||
assert!(
|
||
response
|
||
.form_fields
|
||
.credential
|
||
.ends_with("/cn-shanghai/oss/aliyun_v4_request")
|
||
);
|
||
assert_eq!(response.form_fields.date.len(), "20260507T120000Z".len());
|
||
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", "$x-oss-signature-version", "OSS4-HMAC-SHA256"])
|
||
);
|
||
assert_eq!(
|
||
policy["conditions"][5],
|
||
json!(["eq", "$x-oss-credential", response.form_fields.credential])
|
||
);
|
||
assert_eq!(
|
||
policy["conditions"][6],
|
||
json!(["eq", "$x-oss-date", response.form_fields.date])
|
||
);
|
||
assert_eq!(
|
||
policy["conditions"][7],
|
||
json!(["eq", "$content-type", "image/png"])
|
||
);
|
||
assert_eq!(response.bucket, "genarrative-assets".to_string());
|
||
}
|
||
|
||
#[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())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sign_get_object_url_returns_signed_private_read_url() {
|
||
let client = build_client();
|
||
|
||
let response = client
|
||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||
object_key: "generated-characters/hero_001/visual/asset_01/master.png".to_string(),
|
||
expire_seconds: Some(300),
|
||
})
|
||
.expect("signed get url should build");
|
||
|
||
assert_eq!(response.bucket, "genarrative-assets".to_string());
|
||
assert_eq!(
|
||
response.object_key,
|
||
"generated-characters/hero_001/visual/asset_01/master.png".to_string()
|
||
);
|
||
assert!(response
|
||
.signed_url
|
||
.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/generated-characters/hero_001/visual/asset_01/master.png?"));
|
||
assert!(
|
||
response
|
||
.signed_url
|
||
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
|
||
);
|
||
assert!(
|
||
response
|
||
.signed_url
|
||
.contains("x-oss-credential=test-access-key-id%2F")
|
||
);
|
||
assert!(response.signed_url.contains("&x-oss-expires=300"));
|
||
assert!(response.signed_url.contains("&x-oss-signature="));
|
||
}
|
||
|
||
#[test]
|
||
fn sign_get_object_url_uses_square_hole_object_key_without_bucket_prefix() {
|
||
let client = OssClient::new(
|
||
OssConfig::new(
|
||
"xushi-dev".to_string(),
|
||
"oss-cn-shanghai.aliyuncs.com".to_string(),
|
||
"test-access-key-id".to_string(),
|
||
"test-access-key-secret".to_string(),
|
||
DEFAULT_READ_EXPIRE_SECONDS,
|
||
DEFAULT_POST_EXPIRE_SECONDS,
|
||
DEFAULT_POST_MAX_SIZE_BYTES,
|
||
DEFAULT_SUCCESS_ACTION_STATUS,
|
||
)
|
||
.expect("OSS config should be valid"),
|
||
);
|
||
|
||
let response = client
|
||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||
object_key: "generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string(),
|
||
expire_seconds: Some(300),
|
||
})
|
||
.expect("square hole object key should build signed url");
|
||
|
||
assert_eq!(response.bucket, "xushi-dev".to_string());
|
||
assert_eq!(
|
||
response.object_key,
|
||
"generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string()
|
||
);
|
||
assert!(response
|
||
.signed_url
|
||
.starts_with("https://xushi-dev.oss-cn-shanghai.aliyuncs.com/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png?"));
|
||
}
|
||
|
||
#[test]
|
||
fn sign_get_object_url_rejects_unsupported_prefix() {
|
||
let client = build_client();
|
||
|
||
let error = client
|
||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||
object_key: "workflow-cache/task-1.json".to_string(),
|
||
expire_seconds: Some(300),
|
||
})
|
||
.expect_err("unsupported prefix should fail");
|
||
|
||
assert_eq!(
|
||
error,
|
||
OssError::InvalidRequest("objectKey 必须落在受支持的 generated-* 前缀下".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_prefix_can_be_resolved_from_object_key() {
|
||
assert_eq!(
|
||
LegacyAssetPrefix::from_object_key(
|
||
"generated-custom-world-scenes/profile_01/landmark_01/scene.png"
|
||
),
|
||
Some(LegacyAssetPrefix::CustomWorldScenes)
|
||
);
|
||
assert_eq!(
|
||
LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn put_object_request_reuses_generated_object_key_contract() {
|
||
let request = OssPutObjectRequest {
|
||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||
path_segments: vec!["Profile 001".to_string(), "asset_01".to_string()],
|
||
file_name: "Cover.PNG".to_string(),
|
||
content_type: Some(" image/png ".to_string()),
|
||
access: OssObjectAccess::Private,
|
||
metadata: BTreeMap::from([
|
||
("asset_kind".to_string(), "custom_world_cover".to_string()),
|
||
("source job id".to_string(), "job_001".to_string()),
|
||
]),
|
||
body: b"cover-bytes".to_vec(),
|
||
};
|
||
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).expect("file name should sanitize");
|
||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||
let metadata = normalize_metadata(request.metadata).expect("metadata should normalize");
|
||
|
||
assert_eq!(
|
||
object_key,
|
||
"generated-custom-world-covers/profile-001/asset_01/cover.png"
|
||
);
|
||
assert_eq!(
|
||
metadata.get("x-oss-meta-asset-kind"),
|
||
Some(&"custom_world_cover".to_string())
|
||
);
|
||
assert_eq!(
|
||
metadata.get("x-oss-meta-source-job-id"),
|
||
Some(&"job_001".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
|
||
let headers = BTreeMap::from([
|
||
(
|
||
"x-oss-meta-source-job-id".to_string(),
|
||
" job_001 ".to_string(),
|
||
),
|
||
(
|
||
"x-oss-meta-asset-kind".to_string(),
|
||
"character_visual".to_string(),
|
||
),
|
||
]);
|
||
|
||
assert_eq!(
|
||
build_v4_canonical_headers(&headers),
|
||
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn put_object_rejects_empty_body_before_calling_oss() {
|
||
let client = build_client();
|
||
let error = client
|
||
.put_object(
|
||
&reqwest::Client::new(),
|
||
OssPutObjectRequest {
|
||
prefix: LegacyAssetPrefix::Characters,
|
||
path_segments: vec!["hero".to_string()],
|
||
file_name: "master.png".to_string(),
|
||
content_type: Some("image/png".to_string()),
|
||
access: OssObjectAccess::Private,
|
||
metadata: BTreeMap::new(),
|
||
body: Vec::new(),
|
||
},
|
||
)
|
||
.await
|
||
.expect_err("empty server upload should fail before network");
|
||
|
||
assert_eq!(
|
||
error,
|
||
OssError::InvalidRequest("服务端上传对象内容不能为空".to_string())
|
||
);
|
||
}
|
||
}
|