Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
355 changes: 355 additions & 0 deletions src/aliyun/cdn.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

/// Object path for filtering tasks
#[serde(skip_serializing_if = "Option::is_none")]
pub object_path: Option<String>,

/// Page number (starting from 1)
#[serde(skip_serializing_if = "Option::is_none")]
pub page_number: Option<i32>,

/// Page size (default 20, max 100)
#[serde(skip_serializing_if = "Option::is_none")]
pub page_size: Option<i32>,

/// Task type filter: "file" or "directory"
#[serde(skip_serializing_if = "Option::is_none")]
pub object_type: Option<String>,

/// Domain name filter
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_name: Option<String>,

/// Status filter: "Complete", "Refreshing", "Failed"
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,

/// Start time (ISO 8601 format)
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<String>,

/// End time (ISO 8601 format)
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<String>,
}

/// 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<RefreshTask>,
}

#[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<String>,
}

/// 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)
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as Comment 1 - the documentation mentions newline separation but there's no validation or parsing. The documentation should clarify that formatting is the caller's responsibility, or validation should be added.

Copilot uses AI. Check for mistakes.
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<String>,

/// Whether to directly delete CDN cache nodes (default false)
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as Comment 3 - documentation mentions 'default false' but no #[serde(default)] is applied to enforce this default.

Copilot uses AI. Check for mistakes.
#[serde(skip_serializing_if = "Option::is_none")]
pub force: Option<bool>,
}

/// 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<DescribeRefreshTasksResponse> {
// 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<RefreshObjectCachesResponse> {
// 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, String>) -> String {
params
.iter()
.map(|(k, v)| format!("{}={}", form_urlencode(k), form_urlencode(v)))
.collect::<Vec<_>>()
.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
}
8 changes: 8 additions & 0 deletions src/aliyun/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
pub mod cdn;
mod signature;

pub use cdn::{
AliyunCdnClient, DescribeRefreshTasksRequest, DescribeRefreshTasksResponse,
RefreshObjectCachesRequest, RefreshObjectCachesResponse,
};
pub use signature::AliyunSigner;
Loading