diff --git a/Cargo.lock b/Cargo.lock index e401ff9..a0dcda4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1464,6 +1464,7 @@ dependencies = [ "chrono", "clap", "futures", + "hmac", "jsonwebtoken", "mimalloc", "rand 0.8.5", @@ -1472,6 +1473,7 @@ dependencies = [ "serde", "serde_json", "serde_variant", + "sha2", "sqlx", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 17277bd..00da353 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,3 +66,5 @@ 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" 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..43c962d --- /dev/null +++ b/src/aliyun/cdn.rs @@ -0,0 +1,355 @@ +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::{AliyunSignInput, AliyunSigner}; + +/// CDN API endpoint +const CDN_ENDPOINT: &str = "https://cdn.aliyuncs.com"; +const CDN_HOST: &str = "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 = "CDNTask")] + pub cdn_tasks: 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, +} + +/// 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 directly delete CDN cache nodes (default false) + #[serde(skip_serializing_if = "Option::is_none")] + pub force: 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, + 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 query parameters (V3: Action/Version are sent as x-acs-* headers) + let mut params = BTreeMap::new(); + + // 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 (ACS3-HMAC-SHA256) + 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) + } else { + 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) + } + + /// Call RefreshObjectCaches API + /// + /// # Arguments + /// * `request` - Request parameters + /// + /// # Returns + /// Response containing refresh task ID + pub async fn refresh_object_caches( + &self, + request: &RefreshObjectCachesRequest, + ) -> AppResult { + // 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 { + form_params.insert("ObjectType".to_string(), object_type.clone()); + } + if let Some(force) = request.force { + form_params.insert("Force".to_string(), force.to_string()); + } + + 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 { + method: "POST", + host: CDN_HOST, + canonical_uri: "/", + action: "RefreshObjectCaches", + version: "2018-05-10", + query_params: BTreeMap::new(), + body: form_body.as_bytes(), + 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) + .body(form_body) + .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) + } +} + +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/aliyun/mod.rs b/src/aliyun/mod.rs new file mode 100644 index 0000000..8d1b705 --- /dev/null +++ b/src/aliyun/mod.rs @@ -0,0 +1,8 @@ +pub mod cdn; +mod signature; + +pub use cdn::{ + AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse, + RefreshObjectCachesRequest, RefreshObjectCachesResponse, +}; +pub use signature::AliyunSigner; diff --git a/src/aliyun/signature.rs b/src/aliyun/signature.rs new file mode 100644 index 0000000..67e0c41 --- /dev/null +++ b/src/aliyun/signature.rs @@ -0,0 +1,347 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// Aliyun OpenAPI V3 signature generator (ACS3-HMAC-SHA256) +/// +/// 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, +} + +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 { + access_key_id, + access_key_secret, + } + } + + /// Generate a random nonce (hex string) for request. + fn generate_nonce() -> 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) + fn get_timestamp() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() + } + + fn build_canonical_query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v))) + .collect::>() + .join("&") + } + + /// Canonicalize a request path (CanonicalURI). + /// + /// 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 + } + + 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(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 + all x-acs-* headers (except Authorization). content-type is included if present. + let mut signing_headers: BTreeMap = BTreeMap::new(); + + 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-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()); + } + + let canonical_headers = signing_headers + .iter() + .map(|(k, v)| format!("{}:{}\n", k, v.trim())) + .collect::(); + let signed_headers = signing_headers + .keys() + .cloned() + .collect::>() + .join(";"); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + input.method.to_uppercase(), + canonical_uri, + canonical_query, + canonical_headers, + signed_headers, + x_acs_content_sha256 + ); + + 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!( + "ACS3-HMAC-SHA256 Credential={},SignedHeaders={},Signature={}", + self.access_key_id, signed_headers, signature + ); + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::HOST, + host.parse().context("invalid host header value")?, + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-action"), + action + .parse() + .context("invalid x-acs-action header value")?, + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-version"), + version + .parse() + .context("invalid x-acs-version header value")?, + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-date"), + 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() + .context("invalid x-acs-signature-nonce header value")?, + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-acs-content-sha256"), + x_acs_content_sha256 + .parse() + .context("invalid x-acs-content-sha256 header value")?, + ); + 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() + .context("invalid authorization header value")?, + ); + + Ok(AliyunSignedRequest { + query_string: canonical_query, + headers, + }) + } +} + +/// 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() +} + +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::*; + + #[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()); + + // Keep deterministic verification without injecting timestamp/nonce into `sign_request`. + 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" + ); + } +} 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..24595d0 --- /dev/null +++ b/src/routes/aliyun_handlers.rs @@ -0,0 +1,143 @@ +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::aliyun::{ + AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse, + RefreshObjectCachesRequest, RefreshObjectCachesResponse, +}; +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)) +} + +/// 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, + + /// Whether to directly delete CDN cache nodes (default false) + #[serde(skip_serializing_if = "Option::is_none")] + pub force: 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, + force: payload.force, + }; + + // 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 1bd9553..17c1391 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,18 @@ 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, + aliyun_handlers::RefreshObjectCachesPayload, + crate::aliyun::DescribeRefreshTasksResponse, + crate::aliyun::RefreshObjectCachesResponse, + crate::aliyun::cdn::TasksContainer, + crate::aliyun::cdn::RefreshTask, + ) ), modifiers(&SecurityAddon) )] @@ -50,6 +60,9 @@ 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)) + .routes(routes!(aliyun_handlers::refresh_object_caches)) .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(), } } 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()