Consolidate workspace deps and migrate sha1 to sha2
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-07 21:11:14 +08:00
parent 9146e5b8ec
commit df80876f60
41 changed files with 949 additions and 342 deletions

View File

@@ -5,51 +5,50 @@ version.workspace = true
license.workspace = true
[dependencies]
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
dotenvy = "0.15"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
webp = "0.3"
module-ai = { path = "../module-ai" }
module-assets = { path = "../module-assets" }
module-auth = { path = "../module-auth" }
module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-square-hole = { path = "../module-square-hole" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }
platform-llm = { path = "../platform-llm" }
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
shared-logging = { path = "../shared-logging" }
spacetime-client = { path = "../spacetime-client" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
tokio-stream = "0.1"
time = { version = "0.3", features = ["formatting"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
async-stream = { workspace = true }
axum = { workspace = true }
base64 = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-big-fish = { workspace = true }
module-combat = { workspace = true }
module-custom-world = { workspace = true }
module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
module-square-hole = { workspace = true }
module-story = { workspace = true }
platform-auth = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true }
shared-logging = { workspace = true }
spacetime-client = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
tokio-stream = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tower-http = { workspace = true, features = ["trace"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
sha1 = "0.10"
tower = { version = "0.5", features = ["util"] }
base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
sha2 = { workspace = true }
tower = { workspace = true, features = ["util"] }

View File

@@ -473,26 +473,23 @@ mod tests {
error::Error,
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
use httpdate::fmt_http_date;
use reqwest::{Method, multipart};
use serde_json::{Value, json};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::new_uuid_simple_string;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
#[test]
fn asset_history_kind_support_includes_puzzle_cover_image() {
@@ -653,8 +650,13 @@ mod tests {
Value::String("private".to_string())
);
assert_eq!(
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
Value::String("test-access-key-id".to_string())
payload["data"]["upload"]["formFields"]["x-oss-signature-version"],
Value::String("OSS4-HMAC-SHA256".to_string())
);
assert!(
payload["data"]["upload"]["formFields"]["x-oss-credential"]
.as_str()
.is_some_and(|value| value.starts_with("test-access-key-id/"))
);
assert!(payload["data"]["upload"].get("publicUrl").is_none());
}
@@ -702,7 +704,7 @@ mod tests {
assert!(
payload["data"]["read"]["signedUrl"]
.as_str()
.is_some_and(|value| value.contains("OSSAccessKeyId=test-access-key-id"))
.is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256"))
);
}
@@ -1410,13 +1412,26 @@ mod tests {
.oss_access_key_secret
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key secret"))?;
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!("/{bucket}/{}", object_key.trim_start_matches('/')),
None => format!("/{bucket}/"),
};
let string_to_sign = format!("{}\n\n\n{}\n{}", method.as_str(), date, canonical_resource);
let signature = sign_oss_string(access_key_secret, &string_to_sign)?;
let signed_at = time::OffsetDateTime::now_utc();
let signed_at_text = build_oss_v4_signature_date(signed_at);
let signature_scope = build_oss_v4_signature_scope(endpoint, signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
let payload_hash = "UNSIGNED-PAYLOAD";
let canonical_headers =
format!("host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n");
let additional_headers = "host";
let canonical_request = format!(
"{}\n{}\n\n{}\n{}\n{}",
method.as_str(),
canonical_uri,
canonical_headers,
additional_headers,
payload_hash
);
let string_to_sign =
build_oss_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_oss_v4_content(access_key_secret, &signature_scope, &string_to_sign)?;
let target_url = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => build_object_url(config, object_key)?,
None => reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?,
@@ -1424,18 +1439,147 @@ mod tests {
let response = client
.request(method, target_url)
.header("Date", date)
.header("Authorization", format!("OSS {access_key_id}:{signature}"))
.header("x-oss-content-sha256", payload_hash)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!(
"OSS4-HMAC-SHA256 Credential={access_key_id}/{signature_scope},AdditionalHeaders={additional_headers},Signature={signature}"
),
)
.send()
.await?;
Ok(response)
}
fn sign_oss_string(secret: &str, content: &str) -> Result<String, Box<dyn Error>> {
let mut signer = HmacSha1::new_from_slice(secret.as_bytes())?;
fn build_oss_v4_signature_scope(
endpoint: &str,
signed_at: time::OffsetDateTime,
) -> Result<String, Box<dyn Error>> {
let date = signed_at.date().to_string().replace('-', "");
let region = endpoint
.trim()
.split('.')
.next()
.and_then(|segment| segment.strip_prefix("oss-"))
.ok_or_else(|| std::io::Error::other("OSS endpoint 无法解析 region"))?;
Ok(format!("{date}/{region}/oss/aliyun_v4_request"))
}
fn build_oss_v4_signature_date(signed_at: time::OffsetDateTime) -> String {
let date = signed_at.date().to_string().replace('-', "");
let time = signed_at
.time()
.to_string()
.split('.')
.next()
.unwrap_or("00:00:00")
.replace(':', "");
debug_assert_eq!(time.len(), 6);
format!("{date}T{time}Z")
}
fn build_oss_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_oss_url_query_value(bucket),
encode_oss_url_path(object_key.trim_start_matches('/'))
),
None => format!("/{}/", encode_oss_url_query_value(bucket)),
}
}
fn build_oss_v4_string_to_sign(
signature_date: &str,
signature_scope: &str,
canonical_request: &str,
) -> String {
format!(
"OSS4-HMAC-SHA256\n{signature_date}\n{signature_scope}\n{}",
sha256_hex(canonical_request.as_bytes())
)
}
fn sign_oss_v4_content(
secret: &str,
signature_scope: &str,
content: &str,
) -> Result<String, Box<dyn Error>> {
let signing_key = build_oss_v4_signing_key(secret, signature_scope)?;
let mut signer = HmacSha256::new_from_slice(&signing_key)?;
signer.update(content.as_bytes());
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
Ok(hex_lower(&signer.finalize().into_bytes()))
}
fn build_oss_v4_signing_key(
secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, Box<dyn Error>> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少日期"))?;
let region = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 region"))?;
let service = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 service"))?;
let request = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 request"))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{secret}").as_bytes(), date)?;
let region_key = hmac_sha256_raw(&date_key, region)?;
let service_key = hmac_sha256_raw(&region_key, service)?;
hmac_sha256_raw(&service_key, request)
}
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let mut signer = HmacSha256::new_from_slice(key)?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
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 encode_oss_url_path(path: &str) -> String {
path.split('/')
.map(encode_oss_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_oss_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
}
fn ensure_success_status(status: u16, message: &str) -> Result<(), Box<dyn Error>> {

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -10,8 +10,8 @@ server-service = ["dep:platform-oss", "dep:reqwest"]
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true }
serde = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"], optional = true }
spacetimedb = { workspace = true, optional = true }
platform-oss = { path = "../platform-oss", optional = true }
shared-kernel = { path = "../shared-kernel" }
platform-oss = { workspace = true, optional = true }
shared-kernel = { workspace = true }

View File

@@ -5,12 +5,12 @@ version.workspace = true
license.workspace = true
[dependencies]
platform-auth = { path = "../platform-auth" }
shared-kernel = { path = "../shared-kernel" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["formatting", "parsing"] }
tracing = "0.1"
platform-auth = { workspace = true }
shared-kernel = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
time = { workspace = true, features = ["formatting", "parsing"] }
tracing = { workspace = true }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -9,7 +9,7 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,7 +9,7 @@ default = []
spacetime-types = ["dep:spacetimedb", "module-runtime-item/spacetime-types"]
[dependencies]
module-runtime-item = { path = "../module-runtime-item", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
module-runtime-item = { workspace = true }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde = { workspace = true }
serde_json = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,7 +9,7 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
module-inventory = { path = "../module-inventory", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
module-inventory = { workspace = true }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -5,7 +5,7 @@ version.workspace = true
license.workspace = true
[dependencies]
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["formatting"] }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true }
time = { workspace = true, features = ["formatting"] }

View File

@@ -9,8 +9,8 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }
time = { version = "0.3", features = ["formatting", "parsing"] }
time = { workspace = true, features = ["formatting", "parsing"] }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -16,11 +16,11 @@ spacetime-types = [
]
[dependencies]
module-combat = { path = "../module-combat", default-features = false }
module-inventory = { path = "../module-inventory", default-features = false }
module-progression = { path = "../module-progression", default-features = false }
module-quest = { path = "../module-quest", default-features = false }
module-runtime-item = { path = "../module-runtime-item", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
module-combat = { workspace = true }
module-inventory = { workspace = true }
module-progression = { workspace = true }
module-quest = { workspace = true }
module-runtime-item = { workspace = true }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -5,21 +5,20 @@ version.workspace = true
license.workspace = true
[dependencies]
argon2 = "0.5"
base64 = "0.22"
hmac = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
jsonwebtoken = "9"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["std"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
argon2 = { workspace = true }
hmac = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
rand_core = { workspace = true, features = ["getrandom"] }
serde = { workspace = true }
shared-kernel = { workspace = true }
time = { workspace = true, features = ["std"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -5,7 +5,6 @@ use std::{
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
@@ -14,7 +13,6 @@ use rand_core::OsRng;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
use time::{Duration, OffsetDateTime};
@@ -43,7 +41,7 @@ pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/sns/oauth2/access_token";
pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo";
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -927,14 +925,6 @@ impl AliyunSmsAuthProvider {
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
query.insert("Format".to_string(), "json".to_string());
query.insert("Version".to_string(), "2017-05-25".to_string());
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
query.insert("SignatureVersion".to_string(), "1.0".to_string());
query.insert(
"AccessKeyId".to_string(),
self.config.access_key_id.clone().unwrap_or_default(),
);
query.insert(
"PhoneNumber".to_string(),
request.national_phone_number.trim().to_string(),
@@ -971,11 +961,12 @@ impl AliyunSmsAuthProvider {
if let Some(scheme_name) = self.config.scheme_name.clone() {
query.insert("SchemeName".to_string(), scheme_name);
}
self.sign_query(&mut query)?;
let signature_headers = self.build_signature_headers("SendSmsVerifyCode", &query)?;
let payload = self
.client
.post(build_aliyun_sms_url(&self.config.endpoint)?)
.headers(signature_headers)
.form(&query)
.send()
.await
@@ -1053,14 +1044,6 @@ impl AliyunSmsAuthProvider {
query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string());
query.insert("Format".to_string(), "json".to_string());
query.insert("Version".to_string(), "2017-05-25".to_string());
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
query.insert("SignatureVersion".to_string(), "1.0".to_string());
query.insert(
"AccessKeyId".to_string(),
self.config.access_key_id.clone().unwrap_or_default(),
);
query.insert(
"PhoneNumber".to_string(),
request.national_phone_number.trim().to_string(),
@@ -1080,11 +1063,12 @@ impl AliyunSmsAuthProvider {
if let Some(provider_out_id) = request.provider_out_id {
query.insert("OutId".to_string(), provider_out_id);
}
self.sign_query(&mut query)?;
let signature_headers = self.build_signature_headers("CheckSmsVerifyCode", &query)?;
let payload = self
.client
.post(build_aliyun_sms_url(&self.config.endpoint)?)
.headers(signature_headers)
.form(&query)
.send()
.await
@@ -1105,24 +1089,48 @@ impl AliyunSmsAuthProvider {
Ok(())
}
fn sign_query(&self, query: &mut BTreeMap<String, String>) -> Result<(), SmsProviderError> {
fn build_signature_headers(
&self,
action: &str,
form: &BTreeMap<String, String>,
) -> Result<reqwest::header::HeaderMap, SmsProviderError> {
let access_key_id = self.config.access_key_id.as_deref().ok_or_else(|| {
SmsProviderError::InvalidConfig("阿里云短信 AccessKeyId 未配置".to_string())
})?;
let access_key_secret = self.config.access_key_secret.as_deref().ok_or_else(|| {
SmsProviderError::InvalidConfig("阿里云短信 AccessKeySecret 未配置".to_string())
})?;
let canonicalized = canonicalize_aliyun_rpc_params(query);
let string_to_sign = format!(
"POST&{}&{}",
aliyun_percent_encode("/"),
aliyun_percent_encode(&canonicalized)
let date = current_aliyun_timestamp();
let nonce = new_uuid_simple_string();
let payload = build_aliyun_form_body(form);
let payload_hash = sha256_hex(payload.as_bytes());
let canonical_headers = format!(
"host:{}\nx-acs-action:{}\nx-acs-content-sha256:{}\nx-acs-date:{}\nx-acs-signature-nonce:{}\nx-acs-version:2017-05-25\n",
self.config.endpoint, action, payload_hash, date, nonce
);
let mut signer = HmacSha1::new_from_slice(format!("{access_key_secret}&").as_bytes())
.map_err(|error| {
SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}"))
})?;
signer.update(string_to_sign.as_bytes());
let signature = BASE64_STANDARD.encode(signer.finalize().into_bytes());
query.insert("Signature".to_string(), signature);
Ok(())
let signed_headers =
"host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version";
let canonical_request = format!(
"POST\n/\n\n{}\n{}\n{}",
canonical_headers, signed_headers, payload_hash
);
let string_to_sign = format!(
"ACS3-HMAC-SHA256\n{}",
sha256_hex(canonical_request.as_bytes())
);
let signature = hmac_sha256_hex(access_key_secret.as_bytes(), string_to_sign.as_bytes())?;
let authorization = format!(
"ACS3-HMAC-SHA256 Credential={access_key_id},SignedHeaders={signed_headers},Signature={signature}"
);
let mut headers = reqwest::header::HeaderMap::new();
insert_header(&mut headers, "x-acs-action", action)?;
insert_header(&mut headers, "x-acs-version", "2017-05-25")?;
insert_header(&mut headers, "x-acs-date", &date)?;
insert_header(&mut headers, "x-acs-signature-nonce", &nonce)?;
insert_header(&mut headers, "x-acs-content-sha256", &payload_hash)?;
insert_header(&mut headers, "authorization", &authorization)?;
Ok(headers)
}
}
@@ -1453,10 +1461,9 @@ fn current_aliyun_timestamp() -> String {
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn canonicalize_aliyun_rpc_params(params: &BTreeMap<String, String>) -> String {
fn canonicalize_aliyun_form_params(params: &BTreeMap<String, String>) -> String {
params
.iter()
.filter(|(key, _)| key.as_str() != "Signature")
.map(|(key, value)| {
format!(
"{}={}",
@@ -1468,6 +1475,42 @@ fn canonicalize_aliyun_rpc_params(params: &BTreeMap<String, String>) -> String {
.join("&")
}
fn build_aliyun_form_body(params: &BTreeMap<String, String>) -> String {
serde_urlencoded::to_string(params).unwrap_or_else(|_| canonicalize_aliyun_form_params(params))
}
fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> Result<String, SmsProviderError> {
let mut signer = HmacSha256::new_from_slice(key)
.map_err(|error| SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}")))?;
signer.update(content);
Ok(hex_lower(&signer.finalize().into_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 insert_header(
headers: &mut reqwest::header::HeaderMap,
name: &'static str,
value: &str,
) -> Result<(), SmsProviderError> {
let value = reqwest::header::HeaderValue::from_str(value).map_err(|error| {
SmsProviderError::InvalidConfig(format!("构造阿里云短信签名头失败:{error}"))
})?;
headers.insert(reqwest::header::HeaderName::from_static(name), value);
Ok(())
}
fn aliyun_percent_encode(value: &str) -> String {
urlencoding::encode(value)
.into_owned()
@@ -2046,7 +2089,7 @@ mod tests {
}
#[test]
fn canonicalize_aliyun_rpc_params_keeps_sorted_percent_encoded_order() {
fn canonicalize_aliyun_form_params_keeps_sorted_percent_encoded_order() {
let mut params = BTreeMap::new();
params.insert(
"TemplateParam".to_string(),
@@ -2056,11 +2099,53 @@ mod tests {
params.insert("PhoneNumber".to_string(), "13800138000".to_string());
assert_eq!(
canonicalize_aliyun_rpc_params(&params),
canonicalize_aliyun_form_params(&params),
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
);
}
#[test]
fn aliyun_signature_headers_use_acs3_sha256() {
let config = SmsAuthConfig::new(
SmsAuthProviderKind::Aliyun,
DEFAULT_SMS_ENDPOINT.to_string(),
Some("test-access-key-id".to_string()),
Some("test-access-key-secret".to_string()),
"测试签名".to_string(),
"SMS_001".to_string(),
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
DEFAULT_SMS_COUNTRY_CODE.to_string(),
None,
DEFAULT_SMS_CODE_LENGTH,
DEFAULT_SMS_CODE_TYPE,
DEFAULT_SMS_VALID_TIME_SECONDS,
DEFAULT_SMS_INTERVAL_SECONDS,
DEFAULT_SMS_DUPLICATE_POLICY,
DEFAULT_SMS_CASE_AUTH_POLICY,
false,
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
)
.expect("aliyun config should build");
let provider = AliyunSmsAuthProvider {
client: Client::new(),
config,
};
let headers = provider
.build_signature_headers(
"SendSmsVerifyCode",
&BTreeMap::from([("Action".to_string(), "SendSmsVerifyCode".to_string())]),
)
.expect("signature headers should build");
let authorization = headers
.get(reqwest::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.expect("authorization header should exist");
assert!(authorization.starts_with("ACS3-HMAC-SHA256 Credential=test-access-key-id"));
assert!(headers.get("x-acs-content-sha256").is_some());
}
#[test]
fn aliyun_send_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(

View File

@@ -5,11 +5,11 @@ version.workspace = true
license.workspace = true
[dependencies]
log.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["time"] }
log = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -5,14 +5,13 @@ version.workspace = true
license.workspace = true
[dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
time = { version = "0.3", features = ["formatting"] }
base64 = { workspace = true }
hmac = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["formatting"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -1,21 +1,24 @@
use std::{collections::BTreeMap, error::Error, fmt, time::SystemTime};
use std::{collections::BTreeMap, error::Error, fmt};
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;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
type HmacSha1 = Hmac<Sha1>;
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; 8] = [
"generated-character-drafts",
@@ -171,9 +174,13 @@ pub struct OssPutObjectResponse {
pub struct OssPostObjectFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "OSSAccessKeyId")]
pub oss_access_key_id: String,
#[serde(rename = "Signature")]
#[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,
@@ -394,6 +401,10 @@ impl OssClient {
.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,
@@ -402,14 +413,17 @@ impl OssClient {
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_policy(&self.config.access_key_secret, &encoded_policy)?;
let signature =
sign_v4_content(&self.config.access_key_secret, &signature_scope, &encoded_policy)?;
Ok(OssPostObjectResponse {
signature_version: "v1",
signature_version: "v4",
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
@@ -425,7 +439,9 @@ impl OssClient {
form_fields: OssPostObjectFormFields {
key: object_key,
policy: encoded_policy,
oss_access_key_id: self.config.access_key_id.clone(),
signature_version: OSS_V4_ALGORITHM.to_string(),
credential,
date: signature_date,
signature,
success_action_status: success_action_status.to_string(),
content_type,
@@ -458,18 +474,48 @@ impl OssClient {
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_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!(
"{}/{}?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)
object_url_path,
build_canonical_query_string(&query)
);
Ok(OssSignedGetObjectUrlResponse {
@@ -656,6 +702,8 @@ fn build_policy_json(
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 }),
@@ -666,6 +714,9 @@ fn build_policy_json(
"$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 {
@@ -695,10 +746,6 @@ fn build_object_url(
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],
@@ -928,14 +975,6 @@ fn collapse_dashes(value: &str) -> String {
.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()))
}
async fn send_signed_request(
client: &reqwest::Client,
config: &OssConfig,
@@ -966,29 +1005,52 @@ fn signed_request_builder(
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{}{}",
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(),
content_type.unwrap_or_default(),
date,
canonicalized_oss_headers,
canonical_resource
&canonical_uri,
"",
&canonical_headers,
additional_headers,
&body_sha256,
);
let signature = sign_policy(config.access_key_secret(), &string_to_sign)?;
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("Date", date)
.header("x-oss-content-sha256", body_sha256)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!("OSS {}:{}", config.access_key_id(), signature),
format!(
"{OSS_V4_ALGORITHM} Credential={}/{},AdditionalHeaders={},Signature={}",
config.access_key_id(),
signature_scope,
additional_headers,
signature
),
);
if let Some(content_type) = content_type {
@@ -1002,13 +1064,160 @@ fn signed_request_builder(
Ok(builder)
}
fn build_canonicalized_oss_headers(headers: &BTreeMap<String, String>) -> String {
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = signed_at
.date()
.to_string()
.replace('-', "");
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> {
let date = signed_at
.date()
.to_string()
.replace('-', "");
let time = signed_at
.time()
.to_string()
.split('.')
.next()
.unwrap_or("00:00:00")
.replace(':', "");
if time.len() != 6 {
return Err(OssError::Sign("OSS V4 签名时间格式化失败".to_string()));
}
Ok(format!("{date}T{time}Z"))
}
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(&region_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)
@@ -1115,8 +1324,20 @@ mod tests {
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.form_fields.oss_access_key_id,
"test-access-key-id".to_string()
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"),
@@ -1169,6 +1390,18 @@ mod tests {
);
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());
@@ -1206,10 +1439,13 @@ mod tests {
assert!(
response
.signed_url
.contains("OSSAccessKeyId=test-access-key-id")
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
);
assert!(response.signed_url.contains("&Expires="));
assert!(response.signed_url.contains("&Signature="));
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]
@@ -1282,7 +1518,7 @@ mod tests {
}
#[test]
fn canonicalized_oss_headers_matches_oss_v1_upload_signature_shape() {
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
let headers = BTreeMap::from([
(
"x-oss-meta-source-job-id".to_string(),
@@ -1295,7 +1531,7 @@ mod tests {
]);
assert_eq!(
build_canonicalized_oss_headers(&headers),
build_v4_canonical_headers(&headers),
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
);
}

View File

@@ -5,6 +5,6 @@ version.workspace = true
license.workspace = true
[dependencies]
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
platform-oss = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -525,9 +525,13 @@ pub struct DirectUploadTicketPayload {
pub struct DirectUploadTicketFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "OSSAccessKeyId")]
pub oss_access_key_id: String,
#[serde(rename = "Signature")]
#[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,
@@ -615,7 +619,9 @@ impl From<OssPostObjectFormFields> for DirectUploadTicketFormFields {
Self {
key: value.key,
policy: value.policy,
oss_access_key_id: value.oss_access_key_id,
signature_version: value.signature_version,
credential: value.credential,
date: value.date,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
@@ -703,7 +709,7 @@ mod tests {
fn direct_upload_ticket_response_keeps_form_fields_shape() {
let payload = serde_json::to_value(CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(OssPostObjectResponse {
signature_version: "v1",
signature_version: "v4",
provider: "aliyun-oss",
bucket: "genarrative-assets".to_string(),
endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(),
@@ -719,7 +725,9 @@ mod tests {
form_fields: OssPostObjectFormFields {
key: "generated-characters/hero/master.png".to_string(),
policy: "policy".to_string(),
oss_access_key_id: "ak".to_string(),
signature_version: "OSS4-HMAC-SHA256".to_string(),
credential: "ak/20260507/cn-shanghai/oss/aliyun_v4_request".to_string(),
date: "20260507T120000Z".to_string(),
signature: "sig".to_string(),
success_action_status: "200".to_string(),
content_type: Some("image/png".to_string()),
@@ -732,10 +740,14 @@ mod tests {
})
.expect("payload should serialize");
assert_eq!(payload["upload"]["signatureVersion"], json!("v1"));
assert_eq!(payload["upload"]["signatureVersion"], json!("v4"));
assert_eq!(
payload["upload"]["formFields"]["OSSAccessKeyId"],
json!("ak")
payload["upload"]["formFields"]["x-oss-signature-version"],
json!("OSS4-HMAC-SHA256")
);
assert_eq!(
payload["upload"]["formFields"]["x-oss-credential"],
json!("ak/20260507/cn-shanghai/oss/aliyun_v4_request")
);
assert_eq!(
payload["upload"]["formFields"]["x-oss-meta-asset-kind"],

View File

@@ -5,7 +5,7 @@ version.workspace = true
license.workspace = true
[dependencies]
time = { version = "0.3", features = ["formatting", "parsing"] }
time = { workspace = true, features = ["formatting", "parsing"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
uuid = { version = "1", features = ["v4"] }
uuid = { workspace = true, features = ["v4"] }

View File

@@ -5,4 +5,4 @@ version.workspace = true
license.workspace = true
[dependencies]
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }

View File

@@ -5,23 +5,23 @@ version.workspace = true
license.workspace = true
[dependencies]
module-ai = { path = "../module-ai" }
module-assets = { path = "../module-assets" }
module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-square-hole = { path = "../module-square-hole" }
module-story = { path = "../module-story" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
spacetimedb-sdk = "2.1.0"
tokio = { version = "1", features = ["rt", "sync", "time"] }
module-ai = { workspace = true }
module-assets = { workspace = true }
module-big-fish = { workspace = true }
module-combat = { workspace = true }
module-custom-world = { workspace = true }
module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
module-square-hole = { workspace = true }
module-story = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb-sdk = { workspace = true }
tokio = { workspace = true, features = ["rt", "sync", "time"] }

View File

@@ -9,23 +9,23 @@ crate-type = ["cdylib"]
[dependencies]
log = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
spacetimedb-lib = { version = "=2.1.0", default-features = false, features = ["serde"] }
module-ai = { path = "../module-ai", default-features = false, features = ["spacetime-types"] }
module-assets = { path = "../module-assets", default-features = false, features = ["spacetime-types"] }
module-big-fish = { path = "../module-big-fish", default-features = false, features = ["spacetime-types"] }
module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] }
module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] }
module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] }
module-match3d = { path = "../module-match3d", default-features = false }
module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] }
module-puzzle = { path = "../module-puzzle", default-features = false, features = ["spacetime-types"] }
module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] }
module-quest = { path = "../module-quest", default-features = false, features = ["spacetime-types"] }
module-runtime = { path = "../module-runtime", default-features = false, features = ["spacetime-types"] }
module-runtime-item = { path = "../module-runtime-item", default-features = false, features = ["spacetime-types"] }
module-square-hole = { path = "../module-square-hole", default-features = false }
module-story = { path = "../module-story", default-features = false, features = ["spacetime-types"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
module-ai = { workspace = true, features = ["spacetime-types"] }
module-assets = { workspace = true, features = ["spacetime-types"] }
module-big-fish = { workspace = true, features = ["spacetime-types"] }
module-combat = { workspace = true, features = ["spacetime-types"] }
module-inventory = { workspace = true, features = ["spacetime-types"] }
module-custom-world = { workspace = true, features = ["spacetime-types"] }
module-match3d = { workspace = true }
module-npc = { workspace = true, features = ["spacetime-types"] }
module-puzzle = { workspace = true, features = ["spacetime-types"] }
module-progression = { workspace = true, features = ["spacetime-types"] }
module-quest = { workspace = true, features = ["spacetime-types"] }
module-runtime = { workspace = true, features = ["spacetime-types"] }
module-runtime-item = { workspace = true, features = ["spacetime-types"] }
module-square-hole = { workspace = true }
module-story = { workspace = true, features = ["spacetime-types"] }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, features = ["unstable"] }
spacetimedb-lib = { workspace = true, features = ["serde"] }

View File

@@ -1,8 +1,8 @@
use crate::runtime::analytics_date_dimension::analytics_date_dimension;
use crate::*;
use serde::{Deserialize, Serialize};
use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
use spacetimedb_lib::sats::ser::serde::SerializeWrapper;
use spacetimedb::sats::de::serde::DeserializeWrapper;
use spacetimedb::sats::ser::serde::SerializeWrapper;
use std::collections::HashSet;
use crate::big_fish::big_fish_runtime_run;