From 3468e09f673234f63ba399aa9b5d0817f8ff8c07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:12:13 +0000 Subject: [PATCH 01/11] Initial plan From 067169347705fb8bc003810723b697a1981b0ff3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:23:49 +0000 Subject: [PATCH 02/11] Implement Aliyun API signature and DescribeRefreshTasks API Co-authored-by: daflyinbed <21363956+daflyinbed@users.noreply.github.com> --- Cargo.lock | 10 ++ Cargo.toml | 4 + example.toml | 5 + src/aliyun/cdn.rs | 208 ++++++++++++++++++++++++++++++++++ src/aliyun/mod.rs | 5 + src/aliyun/signature.rs | 153 +++++++++++++++++++++++++ src/config.rs | 10 ++ src/lib.rs | 1 + src/routes/aliyun_handlers.rs | 89 +++++++++++++++ src/routes/mod.rs | 12 +- src/state.rs | 4 +- 11 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 src/aliyun/cdn.rs create mode 100644 src/aliyun/mod.rs create mode 100644 src/aliyun/signature.rs create mode 100644 src/routes/aliyun_handlers.rs diff --git a/Cargo.lock b/Cargo.lock index e401ff9..8db49c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1460,10 +1460,12 @@ dependencies = [ "anyhow", "async-trait", "axum", + "base64", "bytes", "chrono", "clap", "futures", + "hmac", "jsonwebtoken", "mimalloc", "rand 0.8.5", @@ -1472,6 +1474,7 @@ dependencies = [ "serde", "serde_json", "serde_variant", + "sha2", "sqlx", "thiserror", "tokio", @@ -1480,6 +1483,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "utoipa", "utoipa-axum", "utoipa-scalar", @@ -3604,6 +3608,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 17277bd..33db6df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,3 +66,7 @@ serde_variant = "0.1.3" reqwest = { version = "0.12.28", features = ["json", "multipart"] } rand = "0.8" jsonwebtoken = "9.3" +sha2 = "0.10" +hmac = "0.12" +base64 = "0.22" +urlencoding = "2.1" diff --git a/example.toml b/example.toml index c7d0a74..315a0b3 100644 --- a/example.toml +++ b/example.toml @@ -30,6 +30,11 @@ host = "http://localhost" sessdata = "your_bilibili_sessdata_cookie" bili_jct = "your_bilibili_bili_jct" +# Aliyun Configuration +[aliyun] +access_key_id = "your_aliyun_access_key_id" +access_key_secret = "your_aliyun_access_key_secret" + # JWT Configuration (required for API authentication) # Generate ES256 key pair (compatible with jsonwebtoken): # openssl ecparam -genkey -name prime256v1 -noout -out private.pem diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs new file mode 100644 index 0000000..089f9ff --- /dev/null +++ b/src/aliyun/cdn.rs @@ -0,0 +1,208 @@ +use crate::config::AliyunConfig; +use crate::error::{AppError, AppResult}; +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use utoipa::ToSchema; + +use super::signature::AliyunSigner; + +/// CDN API endpoint +const CDN_ENDPOINT: &str = "https://cdn.aliyuncs.com"; + +/// Request parameters for DescribeRefreshTasks API +/// +/// Reference: https://help.aliyun.com/zh/cdn/developer-reference/api-cdn-2018-05-10-describerefreshtasks +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DescribeRefreshTasksRequest { + /// Task ID for querying specific task + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + + /// Object path for filtering tasks + #[serde(skip_serializing_if = "Option::is_none")] + pub object_path: Option, + + /// Page number (starting from 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page_number: Option, + + /// Page size (default 20, max 100) + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, + + /// Task type filter: "file" or "directory" + #[serde(skip_serializing_if = "Option::is_none")] + pub object_type: Option, + + /// Domain name filter + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_name: Option, + + /// Status filter: "Complete", "Refreshing", "Failed" + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Start time (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + + /// End time (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, +} + +/// Response from DescribeRefreshTasks API +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct DescribeRefreshTasksResponse { + #[serde(rename = "RequestId")] + pub request_id: String, + + #[serde(rename = "PageNumber")] + pub page_number: i64, + + #[serde(rename = "PageSize")] + pub page_size: i64, + + #[serde(rename = "TotalCount")] + pub total_count: i64, + + #[serde(rename = "Tasks")] + pub tasks: TasksContainer, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TasksContainer { + #[serde(rename = "Task")] + pub task: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct RefreshTask { + #[serde(rename = "TaskId")] + pub task_id: String, + + #[serde(rename = "ObjectPath")] + pub object_path: String, + + #[serde(rename = "ObjectType")] + pub object_type: String, + + #[serde(rename = "Status")] + pub status: String, + + #[serde(rename = "Process")] + pub process: String, + + #[serde(rename = "CreationTime")] + pub creation_time: String, + + #[serde(rename = "Description", skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Aliyun CDN API client +pub struct AliyunCdnClient { + signer: AliyunSigner, + client: reqwest::Client, +} + +impl AliyunCdnClient { + /// Create a new Aliyun CDN client + pub fn new(config: &AliyunConfig, client: reqwest::Client) -> Self { + let signer = AliyunSigner::new( + config.access_key_id.clone(), + config.access_key_secret.clone(), + ); + + Self { signer, client } + } + + /// Call DescribeRefreshTasks API + /// + /// # Arguments + /// * `request` - Request parameters + /// + /// # Returns + /// Response containing refresh task information + pub async fn describe_refresh_tasks( + &self, + request: &DescribeRefreshTasksRequest, + ) -> AppResult { + // Build parameters + let mut params = BTreeMap::new(); + params.insert("Action".to_string(), "DescribeRefreshTasks".to_string()); + params.insert("Version".to_string(), "2018-05-10".to_string()); + + // Add optional parameters + if let Some(ref task_id) = request.task_id { + params.insert("TaskId".to_string(), task_id.clone()); + } + if let Some(ref object_path) = request.object_path { + params.insert("ObjectPath".to_string(), object_path.clone()); + } + if let Some(page_number) = request.page_number { + params.insert("PageNumber".to_string(), page_number.to_string()); + } + if let Some(page_size) = request.page_size { + params.insert("PageSize".to_string(), page_size.to_string()); + } + if let Some(ref object_type) = request.object_type { + params.insert("ObjectType".to_string(), object_type.clone()); + } + if let Some(ref domain_name) = request.domain_name { + params.insert("DomainName".to_string(), domain_name.clone()); + } + if let Some(ref status) = request.status { + params.insert("Status".to_string(), status.clone()); + } + if let Some(ref start_time) = request.start_time { + params.insert("StartTime".to_string(), start_time.clone()); + } + if let Some(ref end_time) = request.end_time { + params.insert("EndTime".to_string(), end_time.clone()); + } + + // Sign the request + let (signed_params, headers) = self.signer.sign_request("GET", params); + + // Build query string + let query_string = signed_params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + let url = format!("{}/?{}", CDN_ENDPOINT, query_string); + + // Send request + let response = self + .client + .get(&url) + .headers(headers) + .send() + .await + .context("Failed to send DescribeRefreshTasks request")?; + + // Parse response + let status = response.status(); + let body = response + .text() + .await + .context("Failed to read response body")?; + + if !status.is_success() { + return Err(AppError::InternalError(anyhow::anyhow!( + "Aliyun API error (status {}): {}", + status, + body + ))); + } + + // Parse JSON response + let result: DescribeRefreshTasksResponse = serde_json::from_str(&body) + .context("Failed to parse DescribeRefreshTasks response")?; + + Ok(result) + } +} diff --git a/src/aliyun/mod.rs b/src/aliyun/mod.rs new file mode 100644 index 0000000..ea27071 --- /dev/null +++ b/src/aliyun/mod.rs @@ -0,0 +1,5 @@ +mod signature; +pub mod cdn; + +pub use signature::AliyunSigner; +pub use cdn::{AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse}; diff --git a/src/aliyun/signature.rs b/src/aliyun/signature.rs new file mode 100644 index 0000000..2b5e7aa --- /dev/null +++ b/src/aliyun/signature.rs @@ -0,0 +1,153 @@ +use chrono::Utc; +use rand::Rng; +use sha2::Sha256; +use std::collections::BTreeMap; + +/// Aliyun API V3 signature generator +/// +/// Implements the Aliyun API signature V3 algorithm as documented at: +/// https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature +pub struct AliyunSigner { + access_key_id: String, + access_key_secret: String, +} + +impl AliyunSigner { + /// Create a new AliyunSigner with credentials + pub fn new(access_key_id: String, access_key_secret: String) -> Self { + Self { + access_key_id, + access_key_secret, + } + } + + /// Generate a random nonce for request + fn generate_nonce() -> String { + let mut rng = rand::thread_rng(); + let nonce: u64 = rng.r#gen(); + nonce.to_string() + } + + /// Get current timestamp in ISO 8601 format (UTC) + fn get_timestamp() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() + } + + /// Build canonical query string from parameters (sorted by key) + fn build_canonical_query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v))) + .collect::>() + .join("&") + } + + /// Sign a request with Aliyun V3 signature algorithm + /// + /// # Arguments + /// * `method` - HTTP method (GET, POST, etc.) + /// * `params` - Request parameters (will be sorted) + /// + /// # Returns + /// A tuple of (signed_params, headers) where: + /// - signed_params: Complete parameters including signature and common params + /// - headers: HTTP headers to include in the request + pub fn sign_request( + &self, + method: &str, + mut params: BTreeMap, + ) -> (BTreeMap, reqwest::header::HeaderMap) { + // Add common parameters + params.insert("AccessKeyId".to_string(), self.access_key_id.clone()); + params.insert("SignatureMethod".to_string(), "HMAC-SHA256".to_string()); + params.insert("SignatureVersion".to_string(), "1.0".to_string()); + params.insert("SignatureNonce".to_string(), Self::generate_nonce()); + params.insert("Timestamp".to_string(), Self::get_timestamp()); + params.insert("Format".to_string(), "JSON".to_string()); + + // Build canonical query string (parameters are already sorted by BTreeMap) + let canonical_query = Self::build_canonical_query_string(¶ms); + + // Build string to sign: METHOD&percent_encode(/)&percent_encode(canonical_query) + let string_to_sign = format!( + "{}&{}&{}", + method.to_uppercase(), + percent_encode("/"), + percent_encode(&canonical_query) + ); + + // Calculate signature using HMAC-SHA256 + let signature = self.calculate_signature(&string_to_sign); + + // Add signature to parameters + params.insert("Signature".to_string(), signature); + + // Build headers + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "Content-Type", + "application/x-www-form-urlencoded".parse().unwrap(), + ); + + (params, headers) + } + + /// Calculate HMAC-SHA256 signature + fn calculate_signature(&self, string_to_sign: &str) -> String { + use hmac::{Hmac, Mac}; + use base64::Engine; + type HmacSha256 = Hmac; + + // Key is AccessKeySecret + "&" + let key = format!("{}&", self.access_key_secret); + + let mut mac = HmacSha256::new_from_slice(key.as_bytes()) + .expect("HMAC can take key of any size"); + mac.update(string_to_sign.as_bytes()); + + // Get result and convert to base64 + let result = mac.finalize(); + base64::engine::general_purpose::STANDARD.encode(result.into_bytes()) + } +} + +/// Percent encode a string according to RFC 3986 +/// +/// This encodes all characters except: A-Z, a-z, 0-9, -, _, ., ~ +fn percent_encode(input: &str) -> String { + input + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{:02X}", byte), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_percent_encode() { + assert_eq!(percent_encode("hello"), "hello"); + assert_eq!(percent_encode("hello world"), "hello%20world"); + assert_eq!(percent_encode("/"), "%2F"); + assert_eq!(percent_encode("="), "%3D"); + assert_eq!(percent_encode("&"), "%26"); + } + + #[test] + fn test_canonical_query_string() { + let mut params = BTreeMap::new(); + params.insert("Action".to_string(), "DescribeRefreshTasks".to_string()); + params.insert("Version".to_string(), "2018-05-10".to_string()); + + let query = AliyunSigner::build_canonical_query_string(¶ms); + // BTreeMap sorts keys, so Action comes before Version + assert!(query.contains("Action=DescribeRefreshTasks")); + assert!(query.contains("Version=2018-05-10")); + } +} diff --git a/src/config.rs b/src/config.rs index ba50be9..c597f2e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,6 +111,15 @@ pub struct JwtConfig { pub public_key: String, } +/// Aliyun configuration for CDN API +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AliyunConfig { + /// Aliyun Access Key ID + pub access_key_id: String, + /// Aliyun Access Key Secret + pub access_key_secret: String, +} + /// Server configuration for application use #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerConfig { @@ -145,6 +154,7 @@ pub struct AppSettings { pub sentry: Option, pub bilibili: BilibiliConfig, pub jwt: JwtConfig, + pub aliyun: AliyunConfig, } impl AppSettings { diff --git a/src/lib.rs b/src/lib.rs index ed65b3d..975bb6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod aliyun; pub mod app; pub mod auth; mod config; diff --git a/src/routes/aliyun_handlers.rs b/src/routes/aliyun_handlers.rs new file mode 100644 index 0000000..09ad0b0 --- /dev/null +++ b/src/routes/aliyun_handlers.rs @@ -0,0 +1,89 @@ +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::aliyun::{AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse}; +use crate::error::AppResult; +use crate::state::AppState; + +/// Request payload for describe refresh tasks endpoint +#[derive(ToSchema, Serialize, Deserialize, Debug)] +pub struct DescribeRefreshTasksPayload { + /// Task ID for querying specific task + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + + /// Object path for filtering tasks + #[serde(skip_serializing_if = "Option::is_none")] + pub object_path: Option, + + /// Page number (starting from 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page_number: Option, + + /// Page size (default 20, max 100) + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, + + /// Task type filter: "file" or "directory" + #[serde(skip_serializing_if = "Option::is_none")] + pub object_type: Option, + + /// Domain name filter + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_name: Option, + + /// Status filter: "Complete", "Refreshing", "Failed" + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Start time (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + + /// End time (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, +} + +/// Query Aliyun CDN refresh tasks +#[utoipa::path( + post, + tag = "aliyun", + path = "/aliyun/describeRefreshTasks", + request_body = DescribeRefreshTasksPayload, + responses( + (status = OK, description = "Successfully retrieved refresh tasks", body = DescribeRefreshTasksResponse), + (status = UNAUTHORIZED, description = "Unauthorized"), + (status = BAD_REQUEST, description = "Invalid request parameters"), + (status = INTERNAL_SERVER_ERROR, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn describe_refresh_tasks( + State(state): State, + Json(payload): Json, +) -> AppResult> { + // Create Aliyun CDN client + let client = AliyunCdnClient::new(&state.aliyun_config, state.http_client.clone()); + + // Build request + let request = DescribeRefreshTasksRequest { + task_id: payload.task_id, + object_path: payload.object_path, + page_number: payload.page_number, + page_size: payload.page_size, + object_type: payload.object_type, + domain_name: payload.domain_name, + status: payload.status, + start_time: payload.start_time, + end_time: payload.end_time, + }; + + // Call API + let response = client.describe_refresh_tasks(&request).await?; + + Ok(Json(response)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1bd9553..936da53 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ #![allow(clippy::needless_for_each)] +mod aliyun_handlers; mod bilibili_handlers; mod misc_handlers; use crate::{auth::jwt_auth_middleware, middleware::apply_axum_middleware, state::AppState}; @@ -12,9 +13,16 @@ use utoipa_scalar::{Scalar, Servable}; tags( (name = "health", description = "Health check endpoints"), (name = "bilibili", description = "Bilibili dynamic posting endpoints"), + (name = "aliyun", description = "Aliyun CDN API endpoints"), ), components( - schemas(bilibili_handlers::DynamicResponse) + schemas( + bilibili_handlers::DynamicResponse, + aliyun_handlers::DescribeRefreshTasksPayload, + crate::aliyun::DescribeRefreshTasksResponse, + crate::aliyun::cdn::TasksContainer, + crate::aliyun::cdn::RefreshTask, + ) ), modifiers(&SecurityAddon) )] @@ -50,6 +58,8 @@ pub fn build_router(state: AppState) -> Router { )) // Bilibili routes (protected by JWT auth) .routes(routes!(bilibili_handlers::create_dynamic)) + // Aliyun routes (protected by JWT auth) + .routes(routes!(aliyun_handlers::describe_refresh_tasks)) .split_for_parts(); openapi.paths.paths = openapi diff --git a/src/state.rs b/src/state.rs index 73bd475..730dd94 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,5 @@ use crate::{ - config::{AppSettings, BilibiliConfig, JwtConfig}, + config::{AliyunConfig, AppSettings, BilibiliConfig, JwtConfig}, repository::PostgresRepository, }; @@ -8,6 +8,7 @@ pub struct AppState { pub repository: PostgresRepository, pub bilibili_config: BilibiliConfig, pub jwt_config: JwtConfig, + pub aliyun_config: AliyunConfig, pub http_client: reqwest::Client, } @@ -21,6 +22,7 @@ pub async fn init_state_with_pg(config: &AppSettings) -> AppState { repository: PostgresRepository { pool }, bilibili_config: config.bilibili.clone(), jwt_config: config.jwt.clone(), + aliyun_config: config.aliyun.clone(), http_client: reqwest::Client::new(), } } From f70e858d864191a4e1efb8e0f0549e16edd505f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:25:41 +0000 Subject: [PATCH 03/11] Fix double URL encoding in CDN query string Co-authored-by: daflyinbed <21363956+daflyinbed@users.noreply.github.com> --- src/aliyun/cdn.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index 089f9ff..c409939 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -166,10 +166,10 @@ impl AliyunCdnClient { // Sign the request let (signed_params, headers) = self.signer.sign_request("GET", params); - // Build query string + // Build query string (parameters already encoded during signing) let query_string = signed_params .iter() - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("&"); From 9ba72c1cc984c43ba019aef39f6b03a232402485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:30:23 +0000 Subject: [PATCH 04/11] Apply rustfmt formatting Co-authored-by: daflyinbed <21363956+daflyinbed@users.noreply.github.com> --- src/aliyun/cdn.rs | 48 +++++++++++++++++------------------ src/aliyun/mod.rs | 4 +-- src/aliyun/signature.rs | 14 +++++----- src/routes/aliyun_handlers.rs | 16 ++++++------ 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index c409939..319a76e 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -11,42 +11,42 @@ use super::signature::AliyunSigner; const CDN_ENDPOINT: &str = "https://cdn.aliyuncs.com"; /// Request parameters for DescribeRefreshTasks API -/// +/// /// Reference: https://help.aliyun.com/zh/cdn/developer-reference/api-cdn-2018-05-10-describerefreshtasks #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DescribeRefreshTasksRequest { /// Task ID for querying specific task #[serde(skip_serializing_if = "Option::is_none")] pub task_id: Option, - + /// Object path for filtering tasks #[serde(skip_serializing_if = "Option::is_none")] pub object_path: Option, - + /// Page number (starting from 1) #[serde(skip_serializing_if = "Option::is_none")] pub page_number: Option, - + /// Page size (default 20, max 100) #[serde(skip_serializing_if = "Option::is_none")] pub page_size: Option, - + /// Task type filter: "file" or "directory" #[serde(skip_serializing_if = "Option::is_none")] pub object_type: Option, - + /// Domain name filter #[serde(skip_serializing_if = "Option::is_none")] pub domain_name: Option, - + /// Status filter: "Complete", "Refreshing", "Failed" #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, - + /// Start time (ISO 8601 format) #[serde(skip_serializing_if = "Option::is_none")] pub start_time: Option, - + /// End time (ISO 8601 format) #[serde(skip_serializing_if = "Option::is_none")] pub end_time: Option, @@ -57,16 +57,16 @@ pub struct DescribeRefreshTasksRequest { pub struct DescribeRefreshTasksResponse { #[serde(rename = "RequestId")] pub request_id: String, - + #[serde(rename = "PageNumber")] pub page_number: i64, - + #[serde(rename = "PageSize")] pub page_size: i64, - + #[serde(rename = "TotalCount")] pub total_count: i64, - + #[serde(rename = "Tasks")] pub tasks: TasksContainer, } @@ -81,22 +81,22 @@ pub struct TasksContainer { pub struct RefreshTask { #[serde(rename = "TaskId")] pub task_id: String, - + #[serde(rename = "ObjectPath")] pub object_path: String, - + #[serde(rename = "ObjectType")] pub object_type: String, - + #[serde(rename = "Status")] pub status: String, - + #[serde(rename = "Process")] pub process: String, - + #[serde(rename = "CreationTime")] pub creation_time: String, - + #[serde(rename = "Description", skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -114,15 +114,15 @@ impl AliyunCdnClient { config.access_key_id.clone(), config.access_key_secret.clone(), ); - + Self { signer, client } } /// Call DescribeRefreshTasks API - /// + /// /// # Arguments /// * `request` - Request parameters - /// + /// /// # Returns /// Response containing refresh task information pub async fn describe_refresh_tasks( @@ -200,8 +200,8 @@ impl AliyunCdnClient { } // Parse JSON response - let result: DescribeRefreshTasksResponse = serde_json::from_str(&body) - .context("Failed to parse DescribeRefreshTasks response")?; + let result: DescribeRefreshTasksResponse = + serde_json::from_str(&body).context("Failed to parse DescribeRefreshTasks response")?; Ok(result) } diff --git a/src/aliyun/mod.rs b/src/aliyun/mod.rs index ea27071..f8a3930 100644 --- a/src/aliyun/mod.rs +++ b/src/aliyun/mod.rs @@ -1,5 +1,5 @@ -mod signature; pub mod cdn; +mod signature; -pub use signature::AliyunSigner; pub use cdn::{AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse}; +pub use signature::AliyunSigner; diff --git a/src/aliyun/signature.rs b/src/aliyun/signature.rs index 2b5e7aa..134726e 100644 --- a/src/aliyun/signature.rs +++ b/src/aliyun/signature.rs @@ -4,7 +4,7 @@ use sha2::Sha256; use std::collections::BTreeMap; /// Aliyun API V3 signature generator -/// +/// /// Implements the Aliyun API signature V3 algorithm as documented at: /// https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature pub struct AliyunSigner { @@ -43,11 +43,11 @@ impl AliyunSigner { } /// Sign a request with Aliyun V3 signature algorithm - /// + /// /// # Arguments /// * `method` - HTTP method (GET, POST, etc.) /// * `params` - Request parameters (will be sorted) - /// + /// /// # Returns /// A tuple of (signed_params, headers) where: /// - signed_params: Complete parameters including signature and common params @@ -94,15 +94,15 @@ impl AliyunSigner { /// Calculate HMAC-SHA256 signature fn calculate_signature(&self, string_to_sign: &str) -> String { - use hmac::{Hmac, Mac}; use base64::Engine; + use hmac::{Hmac, Mac}; type HmacSha256 = Hmac; // Key is AccessKeySecret + "&" let key = format!("{}&", self.access_key_secret); - let mut mac = HmacSha256::new_from_slice(key.as_bytes()) - .expect("HMAC can take key of any size"); + let mut mac = + HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size"); mac.update(string_to_sign.as_bytes()); // Get result and convert to base64 @@ -112,7 +112,7 @@ impl AliyunSigner { } /// Percent encode a string according to RFC 3986 -/// +/// /// This encodes all characters except: A-Z, a-z, 0-9, -, _, ., ~ fn percent_encode(input: &str) -> String { input diff --git a/src/routes/aliyun_handlers.rs b/src/routes/aliyun_handlers.rs index 09ad0b0..a0a366d 100644 --- a/src/routes/aliyun_handlers.rs +++ b/src/routes/aliyun_handlers.rs @@ -12,35 +12,35 @@ pub struct DescribeRefreshTasksPayload { /// Task ID for querying specific task #[serde(skip_serializing_if = "Option::is_none")] pub task_id: Option, - + /// Object path for filtering tasks #[serde(skip_serializing_if = "Option::is_none")] pub object_path: Option, - + /// Page number (starting from 1) #[serde(skip_serializing_if = "Option::is_none")] pub page_number: Option, - + /// Page size (default 20, max 100) #[serde(skip_serializing_if = "Option::is_none")] pub page_size: Option, - + /// Task type filter: "file" or "directory" #[serde(skip_serializing_if = "Option::is_none")] pub object_type: Option, - + /// Domain name filter #[serde(skip_serializing_if = "Option::is_none")] pub domain_name: Option, - + /// Status filter: "Complete", "Refreshing", "Failed" #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, - + /// Start time (ISO 8601 format) #[serde(skip_serializing_if = "Option::is_none")] pub start_time: Option, - + /// End time (ISO 8601 format) #[serde(skip_serializing_if = "Option::is_none")] pub end_time: Option, From d4915868583847a830d3cb963a4460e89de32c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Wed, 14 Jan 2026 22:20:13 +0800 Subject: [PATCH 05/11] fix: tracing whitelist --- src/tracing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tracing.rs b/src/tracing.rs index 4765bfe..e8fd1db 100644 --- a/src/tracing.rs +++ b/src/tracing.rs @@ -12,7 +12,7 @@ use tracing_subscriber::{ use crate::config::{LogFormat, LogLevel, LoggerConfig, SentryConfig}; -const MODULE_WHITELIST: &[&str] = &["tower_http", "sqlx::query", "my_axum_template"]; +const MODULE_WHITELIST: &[&str] = &["tower_http", "sqlx::query", "janus"]; fn init_env_filter(override_filter: Option<&String>, level: &LogLevel) -> EnvFilter { EnvFilter::try_from_default_env() From ca851e8a5f59ad541153100cffa359ac77dbe98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Wed, 14 Jan 2026 22:42:07 +0800 Subject: [PATCH 06/11] fix: aliyun signature --- src/aliyun/cdn.rs | 36 ++--- src/aliyun/signature.rs | 299 +++++++++++++++++++++++++++++++--------- 2 files changed, 257 insertions(+), 78 deletions(-) diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index 319a76e..8abecf5 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -9,6 +9,7 @@ use super::signature::AliyunSigner; /// CDN API endpoint const CDN_ENDPOINT: &str = "https://cdn.aliyuncs.com"; +const CDN_HOST: &str = "cdn.aliyuncs.com"; /// Request parameters for DescribeRefreshTasks API /// @@ -73,8 +74,8 @@ pub struct DescribeRefreshTasksResponse { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TasksContainer { - #[serde(rename = "Task")] - pub task: Vec, + #[serde(rename = "CDNTask")] + pub cdn_tasks: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -129,10 +130,8 @@ impl AliyunCdnClient { &self, request: &DescribeRefreshTasksRequest, ) -> AppResult { - // Build parameters + // Build query parameters (V3: Action/Version are sent as x-acs-* headers) let mut params = BTreeMap::new(); - params.insert("Action".to_string(), "DescribeRefreshTasks".to_string()); - params.insert("Version".to_string(), "2018-05-10".to_string()); // Add optional parameters if let Some(ref task_id) = request.task_id { @@ -163,17 +162,23 @@ impl AliyunCdnClient { params.insert("EndTime".to_string(), end_time.clone()); } - // Sign the request - let (signed_params, headers) = self.signer.sign_request("GET", params); - - // Build query string (parameters already encoded during signing) - let query_string = signed_params - .iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join("&"); + // Sign the request (ACS3-HMAC-SHA256) + let (query_string, headers) = self.signer.sign_request( + "GET", + CDN_HOST, + "/", + "DescribeRefreshTasks", + "2018-05-10", + params, + b"", + None, + ); - let url = format!("{}/?{}", CDN_ENDPOINT, query_string); + let url = if query_string.is_empty() { + format!("{}/", CDN_ENDPOINT) + } else { + format!("{}/?{}", CDN_ENDPOINT, query_string) + }; // Send request let response = self @@ -198,7 +203,6 @@ impl AliyunCdnClient { body ))); } - // Parse JSON response let result: DescribeRefreshTasksResponse = serde_json::from_str(&body).context("Failed to parse DescribeRefreshTasks response")?; diff --git a/src/aliyun/signature.rs b/src/aliyun/signature.rs index 134726e..13aca0f 100644 --- a/src/aliyun/signature.rs +++ b/src/aliyun/signature.rs @@ -1,19 +1,17 @@ use chrono::Utc; -use rand::Rng; -use sha2::Sha256; +use rand::RngCore; +use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -/// Aliyun API V3 signature generator +/// Aliyun OpenAPI V3 signature generator (ACS3-HMAC-SHA256) /// -/// Implements the Aliyun API signature V3 algorithm as documented at: -/// https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature +/// Docs: https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature pub struct AliyunSigner { access_key_id: String, access_key_secret: String, } impl AliyunSigner { - /// Create a new AliyunSigner with credentials pub fn new(access_key_id: String, access_key_secret: String) -> Self { Self { access_key_id, @@ -21,11 +19,11 @@ impl AliyunSigner { } } - /// Generate a random nonce for request + /// Generate a random nonce (hex string) for request. fn generate_nonce() -> String { - let mut rng = rand::thread_rng(); - let nonce: u64 = rng.r#gen(); - nonce.to_string() + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + hex_encode_lower(&bytes) } /// Get current timestamp in ISO 8601 format (UTC) @@ -33,7 +31,6 @@ impl AliyunSigner { Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } - /// Build canonical query string from parameters (sorted by key) fn build_canonical_query_string(params: &BTreeMap) -> String { params .iter() @@ -42,72 +39,139 @@ impl AliyunSigner { .join("&") } - /// Sign a request with Aliyun V3 signature algorithm + /// Canonicalize a request path (CanonicalURI). /// - /// # Arguments - /// * `method` - HTTP method (GET, POST, etc.) - /// * `params` - Request parameters (will be sorted) + /// For RPC-style APIs this is typically just `/`. + fn canonicalize_uri(path: &str) -> String { + if path.is_empty() { + return "/".to_string(); + } + if path == "/" { + return "/".to_string(); + } + + let has_trailing_slash = path.ends_with('/'); + let trimmed = path.trim_matches('/'); + let mut out = String::from("/"); + if !trimmed.is_empty() { + out.push_str( + &trimmed + .split('/') + .map(percent_encode) + .collect::>() + .join("/"), + ); + } + if has_trailing_slash { + out.push('/'); + } + out + } + + /// Sign an OpenAPI V3 request. /// - /// # Returns - /// A tuple of (signed_params, headers) where: - /// - signed_params: Complete parameters including signature and common params - /// - headers: HTTP headers to include in the request + /// Returns `(query_string, headers)` where `query_string` is already RFC3986-encoded. pub fn sign_request( &self, method: &str, - mut params: BTreeMap, - ) -> (BTreeMap, reqwest::header::HeaderMap) { - // Add common parameters - params.insert("AccessKeyId".to_string(), self.access_key_id.clone()); - params.insert("SignatureMethod".to_string(), "HMAC-SHA256".to_string()); - params.insert("SignatureVersion".to_string(), "1.0".to_string()); - params.insert("SignatureNonce".to_string(), Self::generate_nonce()); - params.insert("Timestamp".to_string(), Self::get_timestamp()); - params.insert("Format".to_string(), "JSON".to_string()); - - // Build canonical query string (parameters are already sorted by BTreeMap) - let canonical_query = Self::build_canonical_query_string(¶ms); - - // Build string to sign: METHOD&percent_encode(/)&percent_encode(canonical_query) - let string_to_sign = format!( - "{}&{}&{}", - method.to_uppercase(), - percent_encode("/"), - percent_encode(&canonical_query) + host: &str, + canonical_uri: &str, + action: &str, + version: &str, + query_params: BTreeMap, + body: &[u8], + content_type: Option<&str>, + ) -> (String, reqwest::header::HeaderMap) { + let x_acs_date = Self::get_timestamp(); + let x_acs_signature_nonce = Self::generate_nonce(); + let x_acs_content_sha256 = sha256_hex(body); + + // Build headers participating in signing. + // Must include: host and all x-acs-* headers (except Authorization). + // content-type is included if present. + let mut signing_headers: BTreeMap = BTreeMap::new(); + signing_headers.insert("host".to_string(), host.trim().to_string()); + signing_headers.insert("x-acs-action".to_string(), action.trim().to_string()); + signing_headers.insert( + "x-acs-content-sha256".to_string(), + x_acs_content_sha256.clone(), + ); + signing_headers.insert("x-acs-date".to_string(), x_acs_date.clone()); + signing_headers.insert( + "x-acs-signature-nonce".to_string(), + x_acs_signature_nonce.clone(), ); + signing_headers.insert("x-acs-version".to_string(), version.trim().to_string()); + if let Some(ct) = content_type { + signing_headers.insert("content-type".to_string(), ct.trim().to_string()); + } + + let canonical_headers = signing_headers + .iter() + .map(|(k, v)| format!("{}:{}\n", k, v.trim())) + .collect::(); - // Calculate signature using HMAC-SHA256 - let signature = self.calculate_signature(&string_to_sign); + let signed_headers = signing_headers.keys().cloned().collect::>().join(";"); - // Add signature to parameters - params.insert("Signature".to_string(), signature); + let canonical_query = Self::build_canonical_query_string(&query_params); + let canonical_uri = Self::canonicalize_uri(canonical_uri); - // Build headers - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - "Content-Type", - "application/x-www-form-urlencoded".parse().unwrap(), + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method.to_uppercase(), + canonical_uri, + canonical_query, + canonical_headers, + signed_headers, + x_acs_content_sha256 ); - (params, headers) - } + let hashed_canonical_request = sha256_hex(canonical_request.as_bytes()); + let string_to_sign = format!("ACS3-HMAC-SHA256\n{}", hashed_canonical_request); - /// Calculate HMAC-SHA256 signature - fn calculate_signature(&self, string_to_sign: &str) -> String { - use base64::Engine; - use hmac::{Hmac, Mac}; - type HmacSha256 = Hmac; + let signature = hmac_sha256_hex(&self.access_key_secret, &string_to_sign); - // Key is AccessKeySecret + "&" - let key = format!("{}&", self.access_key_secret); + let authorization = format!( + "ACS3-HMAC-SHA256 Credential={},SignedHeaders={},Signature={}", + self.access_key_id, signed_headers, signature + ); - let mut mac = - HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size"); - mac.update(string_to_sign.as_bytes()); + // Build reqwest headers. + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::HOST, host.parse().expect("valid host")); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-action"), + action.parse().expect("valid header value"), + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-version"), + version.parse().expect("valid header value"), + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-date"), + x_acs_date.parse().expect("valid header value"), + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-signature-nonce"), + x_acs_signature_nonce + .parse() + .expect("valid header value"), + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-content-sha256"), + x_acs_content_sha256 + .parse() + .expect("valid header value"), + ); + if let Some(ct) = content_type { + headers.insert(reqwest::header::CONTENT_TYPE, ct.parse().expect("valid ct")); + } + headers.insert( + reqwest::header::AUTHORIZATION, + authorization.parse().expect("valid authorization"), + ); - // Get result and convert to base64 - let result = mac.finalize(); - base64::engine::general_purpose::STANDARD.encode(result.into_bytes()) + (canonical_query, headers) } } @@ -126,6 +190,32 @@ fn percent_encode(input: &str) -> String { .collect() } +fn sha256_hex(input: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(input); + hex_encode_lower(&hasher.finalize()) +} + +fn hmac_sha256_hex(secret: &str, message: &str) -> String { + use hmac::{Hmac, Mac}; + type HmacSha256 = Hmac; + + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); + mac.update(message.as_bytes()); + let result = mac.finalize().into_bytes(); + hex_encode_lower(&result) +} + +fn hex_encode_lower(input: &[u8]) -> String { + let mut out = String::with_capacity(input.len() * 2); + for b in input { + use std::fmt::Write; + write!(&mut out, "{:02x}", b).expect("write into string"); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -150,4 +240,89 @@ mod tests { assert!(query.contains("Action=DescribeRefreshTasks")); assert!(query.contains("Version=2018-05-10")); } + + #[test] + fn test_v3_signature_example_from_docs() { + // Example from Aliyun docs (V3 request structure & signature) + // https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature + let signer = AliyunSigner::new( + "YourAccessKeyId".to_string(), + "YourAccessKeySecret".to_string(), + ); + + // Build query params (these are the API request parameters in the docs example) + let mut query_params = BTreeMap::new(); + query_params.insert( + "ImageId".to_string(), + "win2019_1809_x64_dtc_zh-cn_40G_alibase_20230811.vhd".to_string(), + ); + query_params.insert("RegionId".to_string(), "cn-shanghai".to_string()); + + // We need deterministic headers (date + nonce) to compare with the example output. + // So we reconstruct the signature the same way `sign_request` does, but with fixed + // x-acs-date and x-acs-signature-nonce. + let method = "POST"; + let host = "ecs.cn-shanghai.aliyuncs.com"; + let canonical_uri = "/"; + let action = "RunInstances"; + let version = "2014-05-26"; + let x_acs_date = "2023-10-26T10:22:32Z"; + let x_acs_signature_nonce = "3156853299f313e23d1673dc12e1703d"; + let x_acs_content_sha256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + let canonical_query = AliyunSigner::build_canonical_query_string(&query_params); + assert_eq!( + canonical_query, + "ImageId=win2019_1809_x64_dtc_zh-cn_40G_alibase_20230811.vhd&RegionId=cn-shanghai" + ); + + let mut signing_headers: BTreeMap = BTreeMap::new(); + signing_headers.insert("host".to_string(), host.to_string()); + signing_headers.insert("x-acs-action".to_string(), action.to_string()); + signing_headers.insert( + "x-acs-content-sha256".to_string(), + x_acs_content_sha256.to_string(), + ); + signing_headers.insert("x-acs-date".to_string(), x_acs_date.to_string()); + signing_headers.insert( + "x-acs-signature-nonce".to_string(), + x_acs_signature_nonce.to_string(), + ); + signing_headers.insert("x-acs-version".to_string(), version.to_string()); + + let canonical_headers = signing_headers + .iter() + .map(|(k, v)| format!("{}:{}\n", k, v)) + .collect::(); + let signed_headers = signing_headers.keys().cloned().collect::>().join(";"); + + assert_eq!( + signed_headers, + "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version" + ); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method, + AliyunSigner::canonicalize_uri(canonical_uri), + canonical_query, + canonical_headers, + signed_headers, + x_acs_content_sha256 + ); + + let hashed_canonical_request = sha256_hex(canonical_request.as_bytes()); + assert_eq!( + hashed_canonical_request, + "7ea06492da5221eba5297e897ce16e55f964061054b7695beedaac1145b1e259" + ); + + let string_to_sign = format!("ACS3-HMAC-SHA256\n{}", hashed_canonical_request); + let signature = hmac_sha256_hex(&signer.access_key_secret, &string_to_sign); + assert_eq!( + signature, + "06563a9e1b43f5dfe96b81484da74bceab24a1d853912eee15083a6f0f3283c0" + ); + } } From 388236fba29121194487d49cfa321d54b300462f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Wed, 14 Jan 2026 23:10:23 +0800 Subject: [PATCH 07/11] refactor: aliyun signature --- Cargo.lock | 8 --- Cargo.toml | 2 - src/aliyun/cdn.rs | 29 ++++---- src/aliyun/signature.rs | 143 +++++++++++++++++++++------------------- 4 files changed, 95 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8db49c0..a0dcda4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1460,7 +1460,6 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", "bytes", "chrono", "clap", @@ -1483,7 +1482,6 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "urlencoding", "utoipa", "utoipa-axum", "utoipa-scalar", @@ -3608,12 +3606,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 33db6df..00da353 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,5 +68,3 @@ rand = "0.8" jsonwebtoken = "9.3" sha2 = "0.10" hmac = "0.12" -base64 = "0.22" -urlencoding = "2.1" diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index 8abecf5..ed4a717 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use utoipa::ToSchema; -use super::signature::AliyunSigner; +use super::signature::{AliyunSignInput, AliyunSigner}; /// CDN API endpoint const CDN_ENDPOINT: &str = "https://cdn.aliyuncs.com"; @@ -163,16 +163,23 @@ impl AliyunCdnClient { } // Sign the request (ACS3-HMAC-SHA256) - let (query_string, headers) = self.signer.sign_request( - "GET", - CDN_HOST, - "/", - "DescribeRefreshTasks", - "2018-05-10", - params, - b"", - None, - ); + let signed = self + .signer + .sign_request(AliyunSignInput { + method: "GET", + host: CDN_HOST, + canonical_uri: "/", + action: "DescribeRefreshTasks", + version: "2018-05-10", + query_params: params, + body: b"", + content_type: None, + extra_headers: BTreeMap::new(), + }) + .context("Failed to sign Aliyun request")?; + + let query_string = signed.query_string; + let headers = signed.headers; let url = if query_string.is_empty() { format!("{}/", CDN_ENDPOINT) diff --git a/src/aliyun/signature.rs b/src/aliyun/signature.rs index 13aca0f..b41c357 100644 --- a/src/aliyun/signature.rs +++ b/src/aliyun/signature.rs @@ -2,6 +2,7 @@ use chrono::Utc; use rand::RngCore; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; +use anyhow::{Context, Result}; /// Aliyun OpenAPI V3 signature generator (ACS3-HMAC-SHA256) /// @@ -11,6 +12,25 @@ pub struct AliyunSigner { access_key_secret: String, } +pub struct AliyunSignInput<'a> { + pub method: &'a str, + pub host: &'a str, + pub canonical_uri: &'a str, + pub action: &'a str, + pub version: &'a str, + pub query_params: BTreeMap, + pub body: &'a [u8], + pub content_type: Option<&'a str>, + /// Any extra request headers. If the name is `x-acs-*`, `host`, or `content-type`, it will be included in the signature. + pub extra_headers: BTreeMap, +} + +pub struct AliyunSignedRequest { + /// RFC3986-encoded canonical query string. + pub query_string: String, + pub headers: reqwest::header::HeaderMap, +} + impl AliyunSigner { pub fn new(access_key_id: String, access_key_secret: String) -> Self { Self { @@ -68,41 +88,44 @@ impl AliyunSigner { out } - /// Sign an OpenAPI V3 request. - /// - /// Returns `(query_string, headers)` where `query_string` is already RFC3986-encoded. - pub fn sign_request( - &self, - method: &str, - host: &str, - canonical_uri: &str, - action: &str, - version: &str, - query_params: BTreeMap, - body: &[u8], - content_type: Option<&str>, - ) -> (String, reqwest::header::HeaderMap) { + pub fn sign_request(&self, input: AliyunSignInput<'_>) -> Result { + let host = input.host.trim(); + let action = input.action.trim(); + let version = input.version.trim(); + let x_acs_date = Self::get_timestamp(); let x_acs_signature_nonce = Self::generate_nonce(); - let x_acs_content_sha256 = sha256_hex(body); + let x_acs_content_sha256 = sha256_hex(input.body); + + // Canonical query + let canonical_query = Self::build_canonical_query_string(&input.query_params); + let canonical_uri = Self::canonicalize_uri(input.canonical_uri); // Build headers participating in signing. - // Must include: host and all x-acs-* headers (except Authorization). - // content-type is included if present. + // Must include: host + all x-acs-* headers (except Authorization). content-type is included if present. let mut signing_headers: BTreeMap = BTreeMap::new(); - signing_headers.insert("host".to_string(), host.trim().to_string()); - signing_headers.insert("x-acs-action".to_string(), action.trim().to_string()); - signing_headers.insert( - "x-acs-content-sha256".to_string(), - x_acs_content_sha256.clone(), - ); + + for (k, v) in input.extra_headers { + let key = k.trim().to_ascii_lowercase(); + if key == "host" || key == "content-type" || key.starts_with("x-acs-") { + signing_headers.insert(key, v.trim().to_string()); + } + } + + signing_headers.insert("host".to_string(), host.to_string()); + signing_headers.insert("x-acs-action".to_string(), action.to_string()); + signing_headers.insert("x-acs-version".to_string(), version.to_string()); signing_headers.insert("x-acs-date".to_string(), x_acs_date.clone()); signing_headers.insert( "x-acs-signature-nonce".to_string(), x_acs_signature_nonce.clone(), ); - signing_headers.insert("x-acs-version".to_string(), version.trim().to_string()); - if let Some(ct) = content_type { + signing_headers.insert( + "x-acs-content-sha256".to_string(), + x_acs_content_sha256.clone(), + ); + + if let Some(ct) = input.content_type { signing_headers.insert("content-type".to_string(), ct.trim().to_string()); } @@ -110,15 +133,11 @@ impl AliyunSigner { .iter() .map(|(k, v)| format!("{}:{}\n", k, v.trim())) .collect::(); - let signed_headers = signing_headers.keys().cloned().collect::>().join(";"); - let canonical_query = Self::build_canonical_query_string(&query_params); - let canonical_uri = Self::canonicalize_uri(canonical_uri); - let canonical_request = format!( "{}\n{}\n{}\n{}\n{}\n{}", - method.to_uppercase(), + input.method.to_uppercase(), canonical_uri, canonical_query, canonical_headers, @@ -128,7 +147,6 @@ impl AliyunSigner { let hashed_canonical_request = sha256_hex(canonical_request.as_bytes()); let string_to_sign = format!("ACS3-HMAC-SHA256\n{}", hashed_canonical_request); - let signature = hmac_sha256_hex(&self.access_key_secret, &string_to_sign); let authorization = format!( @@ -136,42 +154,58 @@ impl AliyunSigner { self.access_key_id, signed_headers, signature ); - // Build reqwest headers. let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::HOST, host.parse().expect("valid host")); + headers.insert( + reqwest::header::HOST, + host.parse().context("invalid host header value")?, + ); headers.insert( reqwest::header::HeaderName::from_static("x-acs-action"), - action.parse().expect("valid header value"), + action + .parse() + .context("invalid x-acs-action header value")?, ); headers.insert( reqwest::header::HeaderName::from_static("x-acs-version"), - version.parse().expect("valid header value"), + version + .parse() + .context("invalid x-acs-version header value")?, ); headers.insert( reqwest::header::HeaderName::from_static("x-acs-date"), - x_acs_date.parse().expect("valid header value"), + x_acs_date + .parse() + .context("invalid x-acs-date header value")?, ); headers.insert( reqwest::header::HeaderName::from_static("x-acs-signature-nonce"), x_acs_signature_nonce .parse() - .expect("valid header value"), + .context("invalid x-acs-signature-nonce header value")?, ); headers.insert( reqwest::header::HeaderName::from_static("x-acs-content-sha256"), x_acs_content_sha256 .parse() - .expect("valid header value"), + .context("invalid x-acs-content-sha256 header value")?, ); - if let Some(ct) = content_type { - headers.insert(reqwest::header::CONTENT_TYPE, ct.parse().expect("valid ct")); + if let Some(ct) = input.content_type { + headers.insert( + reqwest::header::CONTENT_TYPE, + ct.parse().context("invalid content-type header value")?, + ); } headers.insert( reqwest::header::AUTHORIZATION, - authorization.parse().expect("valid authorization"), + authorization + .parse() + .context("invalid authorization header value")?, ); - (canonical_query, headers) + Ok(AliyunSignedRequest { + query_string: canonical_query, + headers, + }) } } @@ -220,27 +254,6 @@ fn hex_encode_lower(input: &[u8]) -> String { mod tests { use super::*; - #[test] - fn test_percent_encode() { - assert_eq!(percent_encode("hello"), "hello"); - assert_eq!(percent_encode("hello world"), "hello%20world"); - assert_eq!(percent_encode("/"), "%2F"); - assert_eq!(percent_encode("="), "%3D"); - assert_eq!(percent_encode("&"), "%26"); - } - - #[test] - fn test_canonical_query_string() { - let mut params = BTreeMap::new(); - params.insert("Action".to_string(), "DescribeRefreshTasks".to_string()); - params.insert("Version".to_string(), "2018-05-10".to_string()); - - let query = AliyunSigner::build_canonical_query_string(¶ms); - // BTreeMap sorts keys, so Action comes before Version - assert!(query.contains("Action=DescribeRefreshTasks")); - assert!(query.contains("Version=2018-05-10")); - } - #[test] fn test_v3_signature_example_from_docs() { // Example from Aliyun docs (V3 request structure & signature) @@ -258,9 +271,7 @@ mod tests { ); query_params.insert("RegionId".to_string(), "cn-shanghai".to_string()); - // We need deterministic headers (date + nonce) to compare with the example output. - // So we reconstruct the signature the same way `sign_request` does, but with fixed - // x-acs-date and x-acs-signature-nonce. + // Keep deterministic verification without injecting timestamp/nonce into `sign_request`. let method = "POST"; let host = "ecs.cn-shanghai.aliyuncs.com"; let canonical_uri = "/"; From 1f423da42a09cbd2332dc350b0ce40fc57b440ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Wed, 14 Jan 2026 23:12:31 +0800 Subject: [PATCH 08/11] fmt --- src/aliyun/signature.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/aliyun/signature.rs b/src/aliyun/signature.rs index b41c357..67e0c41 100644 --- a/src/aliyun/signature.rs +++ b/src/aliyun/signature.rs @@ -1,8 +1,8 @@ +use anyhow::{Context, Result}; use chrono::Utc; use rand::RngCore; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -use anyhow::{Context, Result}; /// Aliyun OpenAPI V3 signature generator (ACS3-HMAC-SHA256) /// @@ -133,7 +133,11 @@ impl AliyunSigner { .iter() .map(|(k, v)| format!("{}:{}\n", k, v.trim())) .collect::(); - let signed_headers = signing_headers.keys().cloned().collect::>().join(";"); + let signed_headers = signing_headers + .keys() + .cloned() + .collect::>() + .join(";"); let canonical_request = format!( "{}\n{}\n{}\n{}\n{}\n{}", @@ -306,7 +310,11 @@ mod tests { .iter() .map(|(k, v)| format!("{}:{}\n", k, v)) .collect::(); - let signed_headers = signing_headers.keys().cloned().collect::>().join(";"); + let signed_headers = signing_headers + .keys() + .cloned() + .collect::>() + .join(";"); assert_eq!( signed_headers, From bdef3c495bc4d666d83fd1051323e8428f24f20e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:24:11 +0000 Subject: [PATCH 09/11] Add RefreshObjectCaches API implementation Co-authored-by: daflyinbed <21363956+daflyinbed@users.noreply.github.com> --- src/aliyun/cdn.rs | 105 ++++++++++++++++++++++++++++++++++ src/aliyun/mod.rs | 5 +- src/routes/aliyun_handlers.rs | 56 +++++++++++++++++- src/routes/mod.rs | 3 + 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index ed4a717..cba0df9 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -102,6 +102,33 @@ pub struct RefreshTask { pub description: Option, } +/// Request parameters for RefreshObjectCaches API +/// +/// Reference: https://help.aliyun.com/zh/cdn/developer-reference/api-cdn-2018-05-10-refreshobjectcaches +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshObjectCachesRequest { + /// Object paths to refresh (separated by newlines, max 1000 URLs or 100 directories per request) + pub object_path: String, + + /// Object type: "File" for file refresh, "Directory" for directory refresh + #[serde(skip_serializing_if = "Option::is_none")] + pub object_type: Option, + + /// Whether to force refresh: "domestic" or "overseas" + #[serde(skip_serializing_if = "Option::is_none")] + pub area: Option, +} + +/// Response from RefreshObjectCaches API +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct RefreshObjectCachesResponse { + #[serde(rename = "RequestId")] + pub request_id: String, + + #[serde(rename = "RefreshTaskId")] + pub refresh_task_id: String, +} + /// Aliyun CDN API client pub struct AliyunCdnClient { signer: AliyunSigner, @@ -216,4 +243,82 @@ impl AliyunCdnClient { Ok(result) } + + /// Call RefreshObjectCaches API + /// + /// # Arguments + /// * `request` - Request parameters + /// + /// # Returns + /// Response containing refresh task ID + pub async fn refresh_object_caches( + &self, + request: &RefreshObjectCachesRequest, + ) -> AppResult { + // Build query parameters + let mut params = BTreeMap::new(); + params.insert("ObjectPath".to_string(), request.object_path.clone()); + + if let Some(ref object_type) = request.object_type { + params.insert("ObjectType".to_string(), object_type.clone()); + } + if let Some(ref area) = request.area { + params.insert("Area".to_string(), area.clone()); + } + + // Sign the request (ACS3-HMAC-SHA256) + let signed = self + .signer + .sign_request(AliyunSignInput { + method: "POST", + host: CDN_HOST, + canonical_uri: "/", + action: "RefreshObjectCaches", + version: "2018-05-10", + query_params: params, + body: b"", + content_type: Some("application/x-www-form-urlencoded"), + extra_headers: BTreeMap::new(), + }) + .context("Failed to sign Aliyun request")?; + + let query_string = signed.query_string; + let headers = signed.headers; + + let url = if query_string.is_empty() { + format!("{}/", CDN_ENDPOINT) + } else { + format!("{}/?{}", CDN_ENDPOINT, query_string) + }; + + // Send request + let response = self + .client + .post(&url) + .headers(headers) + .send() + .await + .context("Failed to send RefreshObjectCaches request")?; + + // Parse response + let status = response.status(); + let body = response + .text() + .await + .context("Failed to read response body")?; + + if !status.is_success() { + return Err(AppError::InternalError(anyhow::anyhow!( + "Aliyun API error (status {}): {}", + status, + body + ))); + } + + // Parse JSON response + let result: RefreshObjectCachesResponse = + serde_json::from_str(&body).context("Failed to parse RefreshObjectCaches response")?; + + Ok(result) + } } diff --git a/src/aliyun/mod.rs b/src/aliyun/mod.rs index f8a3930..8d1b705 100644 --- a/src/aliyun/mod.rs +++ b/src/aliyun/mod.rs @@ -1,5 +1,8 @@ pub mod cdn; mod signature; -pub use cdn::{AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse}; +pub use cdn::{ + AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse, + RefreshObjectCachesRequest, RefreshObjectCachesResponse, +}; pub use signature::AliyunSigner; diff --git a/src/routes/aliyun_handlers.rs b/src/routes/aliyun_handlers.rs index a0a366d..f726b7f 100644 --- a/src/routes/aliyun_handlers.rs +++ b/src/routes/aliyun_handlers.rs @@ -2,7 +2,10 @@ use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::aliyun::{AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse}; +use crate::aliyun::{ + AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse, + RefreshObjectCachesRequest, RefreshObjectCachesResponse, +}; use crate::error::AppResult; use crate::state::AppState; @@ -87,3 +90,54 @@ pub async fn describe_refresh_tasks( Ok(Json(response)) } + +/// Request payload for refresh object caches endpoint +#[derive(ToSchema, Serialize, Deserialize, Debug)] +pub struct RefreshObjectCachesPayload { + /// Object paths to refresh (separated by newlines, max 1000 URLs or 100 directories per request) + pub object_path: String, + + /// Object type: "File" for file refresh, "Directory" for directory refresh + #[serde(skip_serializing_if = "Option::is_none")] + pub object_type: Option, + + /// Refresh area: "domestic" or "overseas" + #[serde(skip_serializing_if = "Option::is_none")] + pub area: Option, +} + +/// Refresh Aliyun CDN object caches +#[utoipa::path( + post, + tag = "aliyun", + path = "/aliyun/refreshObjectCaches", + request_body = RefreshObjectCachesPayload, + responses( + (status = OK, description = "Successfully submitted refresh task", body = RefreshObjectCachesResponse), + (status = UNAUTHORIZED, description = "Unauthorized"), + (status = BAD_REQUEST, description = "Invalid request parameters"), + (status = INTERNAL_SERVER_ERROR, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn refresh_object_caches( + State(state): State, + Json(payload): Json, +) -> AppResult> { + // Create Aliyun CDN client + let client = AliyunCdnClient::new(&state.aliyun_config, state.http_client.clone()); + + // Build request + let request = RefreshObjectCachesRequest { + object_path: payload.object_path, + object_type: payload.object_type, + area: payload.area, + }; + + // Call API + let response = client.refresh_object_caches(&request).await?; + + Ok(Json(response)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 936da53..17c1391 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -19,7 +19,9 @@ use utoipa_scalar::{Scalar, Servable}; schemas( bilibili_handlers::DynamicResponse, aliyun_handlers::DescribeRefreshTasksPayload, + aliyun_handlers::RefreshObjectCachesPayload, crate::aliyun::DescribeRefreshTasksResponse, + crate::aliyun::RefreshObjectCachesResponse, crate::aliyun::cdn::TasksContainer, crate::aliyun::cdn::RefreshTask, ) @@ -60,6 +62,7 @@ pub fn build_router(state: AppState) -> Router { .routes(routes!(bilibili_handlers::create_dynamic)) // Aliyun routes (protected by JWT auth) .routes(routes!(aliyun_handlers::describe_refresh_tasks)) + .routes(routes!(aliyun_handlers::refresh_object_caches)) .split_for_parts(); openapi.paths.paths = openapi From 0853de65dc900f8972f8a5eea0787f51ad40f6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Thu, 15 Jan 2026 00:10:32 +0800 Subject: [PATCH 10/11] fix --- src/aliyun/cdn.rs | 57 ++++++++++++++++++++++++++++------- src/routes/aliyun_handlers.rs | 6 ++-- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index cba0df9..7fc0381 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -114,9 +114,9 @@ pub struct RefreshObjectCachesRequest { #[serde(skip_serializing_if = "Option::is_none")] pub object_type: Option, - /// Whether to force refresh: "domestic" or "overseas" + /// Whether to directly delete CDN cache nodes (default false) #[serde(skip_serializing_if = "Option::is_none")] - pub area: Option, + pub force: Option, } /// Response from RefreshObjectCaches API @@ -255,18 +255,22 @@ impl AliyunCdnClient { &self, request: &RefreshObjectCachesRequest, ) -> AppResult { - // Build query parameters - let mut params = BTreeMap::new(); - params.insert("ObjectPath".to_string(), request.object_path.clone()); + // RefreshObjectCaches is a POST request with parameters in an HTML form body. + // Reference: https://help.aliyun.com/zh/cdn/developer-reference/api-cdn-2018-05-10-refreshobjectcaches + let mut form_params = BTreeMap::new(); + form_params.insert("ObjectPath".to_string(), request.object_path.clone()); if let Some(ref object_type) = request.object_type { - params.insert("ObjectType".to_string(), object_type.clone()); + form_params.insert("ObjectType".to_string(), object_type.clone()); } - if let Some(ref area) = request.area { - params.insert("Area".to_string(), area.clone()); + if let Some(force) = request.force { + form_params.insert("Force".to_string(), force.to_string()); } - // Sign the request (ACS3-HMAC-SHA256) + let form_body = build_form_urlencoded_body(&form_params); + + // Sign the request (ACS3-HMAC-SHA256). For this API, the form body must be included + // in the body hash, so keep the canonical query empty. let signed = self .signer .sign_request(AliyunSignInput { @@ -275,8 +279,8 @@ impl AliyunCdnClient { canonical_uri: "/", action: "RefreshObjectCaches", version: "2018-05-10", - query_params: params, - body: b"", + query_params: BTreeMap::new(), + body: form_body.as_bytes(), content_type: Some("application/x-www-form-urlencoded"), extra_headers: BTreeMap::new(), }) @@ -296,6 +300,7 @@ impl AliyunCdnClient { .client .post(&url) .headers(headers) + .body(form_body) .send() .await .context("Failed to send RefreshObjectCaches request")?; @@ -322,3 +327,33 @@ impl AliyunCdnClient { Ok(result) } } + +fn build_form_urlencoded_body(params: &BTreeMap) -> String { + params + .iter() + .map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v))) + .collect::>() + .join("&") +} + +// application/x-www-form-urlencoded encoding. +// - Space becomes '+' +// - Unreserved characters are not escaped +// - Everything else is percent-encoded with upper-case hex +fn form_urlencode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for &b in input.as_bytes() { + match b { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' => out.push(b as char), + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} diff --git a/src/routes/aliyun_handlers.rs b/src/routes/aliyun_handlers.rs index f726b7f..24595d0 100644 --- a/src/routes/aliyun_handlers.rs +++ b/src/routes/aliyun_handlers.rs @@ -101,9 +101,9 @@ pub struct RefreshObjectCachesPayload { #[serde(skip_serializing_if = "Option::is_none")] pub object_type: Option, - /// Refresh area: "domestic" or "overseas" + /// Whether to directly delete CDN cache nodes (default false) #[serde(skip_serializing_if = "Option::is_none")] - pub area: Option, + pub force: Option, } /// Refresh Aliyun CDN object caches @@ -133,7 +133,7 @@ pub async fn refresh_object_caches( let request = RefreshObjectCachesRequest { object_path: payload.object_path, object_type: payload.object_type, - area: payload.area, + force: payload.force, }; // Call API From 6988a7a8391dc0f1c28a0fd73e291724829e9d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Thu, 15 Jan 2026 00:12:12 +0800 Subject: [PATCH 11/11] fix --- src/aliyun/cdn.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/aliyun/cdn.rs b/src/aliyun/cdn.rs index 7fc0381..43c962d 100644 --- a/src/aliyun/cdn.rs +++ b/src/aliyun/cdn.rs @@ -344,13 +344,9 @@ fn form_urlencode(input: &str) -> String { let mut out = String::with_capacity(input.len()); for &b in input.as_bytes() { match b { - b'A'..=b'Z' - | b'a'..=b'z' - | b'0'..=b'9' - | b'-' - | b'_' - | b'.' - | b'~' => out.push(b as char), + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } b' ' => out.push('+'), _ => out.push_str(&format!("%{:02X}", b)), }