重写
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
use std::{collections::BTreeMap, error::Error, fmt};
|
||||
use std::{collections::BTreeMap, error::Error, fmt, time::SystemTime};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use hmac::{Hmac, Mac};
|
||||
use httpdate::fmt_http_date;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha1::Sha1;
|
||||
@@ -10,6 +12,7 @@ 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_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;
|
||||
@@ -46,7 +49,7 @@ pub struct OssConfig {
|
||||
endpoint: String,
|
||||
access_key_id: String,
|
||||
access_key_secret: String,
|
||||
public_base_url: Option<String>,
|
||||
default_read_expire_seconds: u64,
|
||||
default_post_expire_seconds: u64,
|
||||
default_post_max_size_bytes: u64,
|
||||
default_success_action_status: u16,
|
||||
@@ -65,6 +68,28 @@ pub struct OssPostObjectRequest {
|
||||
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")]
|
||||
@@ -77,8 +102,6 @@ pub struct OssPostObjectResponse {
|
||||
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,
|
||||
@@ -94,6 +117,51 @@ pub struct OssPostObjectResponse {
|
||||
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,
|
||||
@@ -119,6 +187,8 @@ pub struct OssClient {
|
||||
pub enum OssError {
|
||||
InvalidConfig(String),
|
||||
InvalidRequest(String),
|
||||
ObjectNotFound(String),
|
||||
Request(String),
|
||||
SerializePolicy(String),
|
||||
Sign(String),
|
||||
}
|
||||
@@ -157,6 +227,12 @@ impl LegacyAssetPrefix {
|
||||
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 {
|
||||
@@ -166,7 +242,7 @@ impl OssConfig {
|
||||
endpoint: String,
|
||||
access_key_id: String,
|
||||
access_key_secret: String,
|
||||
public_base_url: Option<String>,
|
||||
default_read_expire_seconds: u64,
|
||||
default_post_expire_seconds: u64,
|
||||
default_post_max_size_bytes: u64,
|
||||
default_success_action_status: u16,
|
||||
@@ -176,7 +252,12 @@ impl OssConfig {
|
||||
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_read_expire_seconds == 0 {
|
||||
return Err(OssError::InvalidConfig(
|
||||
"OSS 私有读签名有效期必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if default_post_expire_seconds == 0 {
|
||||
return Err(OssError::InvalidConfig(
|
||||
@@ -201,7 +282,7 @@ impl OssConfig {
|
||||
endpoint,
|
||||
access_key_id,
|
||||
access_key_secret,
|
||||
public_base_url,
|
||||
default_read_expire_seconds,
|
||||
default_post_expire_seconds,
|
||||
default_post_max_size_bytes,
|
||||
default_success_action_status,
|
||||
@@ -219,6 +300,14 @@ impl OssConfig {
|
||||
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 {
|
||||
@@ -226,6 +315,10 @@ impl OssClient {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn config_bucket(&self) -> &str {
|
||||
self.config.bucket()
|
||||
}
|
||||
|
||||
pub fn sign_post_object(
|
||||
&self,
|
||||
request: OssPostObjectRequest,
|
||||
@@ -293,11 +386,6 @@ impl OssClient {
|
||||
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",
|
||||
@@ -306,7 +394,6 @@ impl OssClient {
|
||||
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),
|
||||
@@ -325,14 +412,189 @@ impl OssClient {
|
||||
})
|
||||
}
|
||||
|
||||
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());
|
||||
// 私有 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);
|
||||
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), object_key)
|
||||
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 expires_epoch_seconds = expires_at.unix_timestamp();
|
||||
|
||||
let canonical_resource = build_canonical_object_resource(&self.config.bucket, &object_key);
|
||||
let string_to_sign = format!("GET\n\n\n{expires_epoch_seconds}\n{canonical_resource}");
|
||||
let signature = sign_policy(&self.config.access_key_secret, &string_to_sign)?;
|
||||
let signed_url = format!(
|
||||
"{}/{}?OSSAccessKeyId={}&Expires={}&Signature={}",
|
||||
self.config.upload_host(),
|
||||
encode_url_path(&object_key),
|
||||
encode_url_query_value(&self.config.access_key_id),
|
||||
expires_epoch_seconds,
|
||||
encode_url_query_value(&signature)
|
||||
);
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +603,8 @@ impl fmt::Display for OssError {
|
||||
match self {
|
||||
Self::InvalidConfig(message)
|
||||
| Self::InvalidRequest(message)
|
||||
| Self::ObjectNotFound(message)
|
||||
| Self::Request(message)
|
||||
| Self::SerializePolicy(message)
|
||||
| Self::Sign(message) => f.write_str(message),
|
||||
}
|
||||
@@ -383,6 +647,23 @@ fn build_policy_json(
|
||||
})
|
||||
}
|
||||
|
||||
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_canonical_object_resource(bucket: &str, object_key: &str) -> String {
|
||||
format!("/{bucket}/{object_key}")
|
||||
}
|
||||
|
||||
fn build_object_key(
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: &[String],
|
||||
@@ -395,6 +676,42 @@ fn build_object_key(
|
||||
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());
|
||||
@@ -541,10 +858,6 @@ fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -588,6 +901,105 @@ fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result<String,
|
||||
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
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 date = fmt_http_date(SystemTime::now());
|
||||
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(object_key) => {
|
||||
build_canonical_object_resource(config.bucket(), object_key.trim_start_matches('/'))
|
||||
}
|
||||
None => format!("/{}/", config.bucket()),
|
||||
};
|
||||
let canonicalized_oss_headers = build_canonicalized_oss_headers(oss_headers);
|
||||
let string_to_sign = format!(
|
||||
"{}\n\n{}\n{}\n{}{}",
|
||||
method.as_str(),
|
||||
content_type.unwrap_or_default(),
|
||||
date,
|
||||
canonicalized_oss_headers,
|
||||
canonical_resource
|
||||
);
|
||||
let signature = sign_policy(config.access_key_secret(), &string_to_sign)?;
|
||||
let mut builder = client
|
||||
.request(method, target_url)
|
||||
.header("Date", date)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("OSS {}:{}", config.access_key_id(), 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_canonicalized_oss_headers(headers: &BTreeMap<String, String>) -> String {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
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::*;
|
||||
@@ -599,7 +1011,7 @@ mod tests {
|
||||
"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_READ_EXPIRE_SECONDS,
|
||||
DEFAULT_POST_EXPIRE_SECONDS,
|
||||
DEFAULT_POST_MAX_SIZE_BYTES,
|
||||
DEFAULT_SUCCESS_ACTION_STATUS,
|
||||
@@ -618,7 +1030,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_post_object_returns_legacy_compatible_key_and_urls() {
|
||||
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());
|
||||
@@ -650,12 +1062,7 @@ mod tests {
|
||||
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.bucket, "genarrative-assets".to_string());
|
||||
assert_eq!(
|
||||
response.form_fields.oss_access_key_id,
|
||||
"test-access-key-id".to_string()
|
||||
@@ -713,7 +1120,7 @@ mod tests {
|
||||
policy["conditions"][4],
|
||||
json!(["eq", "$content-type", "image/png"])
|
||||
);
|
||||
assert!(response.public_url.is_none());
|
||||
assert_eq!(response.bucket, "genarrative-assets".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -725,4 +1132,145 @@ mod tests {
|
||||
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("OSSAccessKeyId=test-access-key-id")
|
||||
);
|
||||
assert!(response.signed_url.contains("&Expires="));
|
||||
assert!(response.signed_url.contains("&Signature="));
|
||||
}
|
||||
|
||||
#[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_oss_v1_upload_signature_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_canonicalized_oss_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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user