1use std::time::Duration;
23
24use futures::FutureExt;
25use sha2::{Digest, Sha256};
26use tokio::sync::Notify;
27
28const API_HOST: &str = "teo.tencentcloudapi.com";
29const API_SERVICE: &str = "teo";
30const API_VERSION: &str = "2022-09-01";
31const API_ACTION: &str = "CreatePurgeTask";
32const CONTENT_TYPE: &str = "application/json";
34
35const DEBOUNCE: Duration = Duration::from_secs(2);
36const SECOND_PASS_DELAY: Duration = Duration::from_secs(30);
39const RETRY_DELAYS: [Duration; 3] = [
40 Duration::from_secs(2),
41 Duration::from_secs(10),
42 Duration::from_secs(30),
43];
44
45static SIGNAL: Notify = Notify::const_new();
46
47pub fn request_purge() {
49 SIGNAL.notify_one();
50}
51
52struct Credentials {
53 zone_id: String,
54 secret_id: String,
55 secret_key: String,
56}
57
58impl Credentials {
59 fn from_env() -> Option<Self> {
60 let read = |name: &str| {
61 std::env::var(name)
62 .ok()
63 .map(|v| v.trim().to_owned())
64 .filter(|v| !v.is_empty())
65 };
66 let zone_id = read("EDGEONE_ZONE_ID");
67 let secret_id = read("TENCENTCLOUD_SECRET_ID");
68 let secret_key = read("TENCENTCLOUD_SECRET_KEY");
69 match (zone_id, secret_id, secret_key) {
70 (Some(zone_id), Some(secret_id), Some(secret_key)) => Some(Self {
71 zone_id,
72 secret_id,
73 secret_key,
74 }),
75 (None, None, None) => None,
76 _ => {
77 tracing::warn!(
78 "EDGEONE_ZONE_ID / TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY 未配置齐,不清边缘缓存"
79 );
80 None
81 }
82 }
83 }
84}
85
86pub fn is_configured() -> bool {
88 [
89 "EDGEONE_ZONE_ID",
90 "TENCENTCLOUD_SECRET_ID",
91 "TENCENTCLOUD_SECRET_KEY",
92 ]
93 .iter()
94 .all(|name| std::env::var(name).is_ok_and(|v| !v.trim().is_empty()))
95}
96
97pub async fn run() {
99 let Some(credentials) = Credentials::from_env() else {
100 return;
101 };
102 let client = match reqwest::Client::builder()
103 .timeout(Duration::from_secs(15))
104 .connect_timeout(Duration::from_secs(5))
105 .user_agent("yggdrasil-edge-purge/1.0")
106 .build()
107 {
108 Ok(client) => client,
109 Err(error) => {
110 tracing::error!(%error, "边缘缓存清理客户端初始化失败,不清边缘缓存");
111 return;
112 }
113 };
114 tracing::info!(zone_id = %credentials.zone_id, "EdgeOne HTML 缓存清理已启用");
115
116 loop {
117 SIGNAL.notified().await;
118 tokio::time::sleep(DEBOUNCE).await;
119 let _ = SIGNAL.notified().now_or_never();
121
122 let base = crate::api::settings::runtime_security_settings()
123 .await
124 .app_base_url;
125 let Some(targets) = PurgeTargets::for_base(&base) else {
126 tracing::warn!("APP_BASE_URL 未设置或不是 http(s) 地址,无法清边缘缓存");
127 continue;
128 };
129 purge_with_retry(&client, &credentials, &targets, "首次").await;
130 tokio::time::sleep(SECOND_PASS_DELAY).await;
132 purge_with_retry(&client, &credentials, &targets, "延迟").await;
133 }
134}
135
136#[derive(Debug, PartialEq)]
138struct PurgeTargets {
139 prefixes: Vec<String>,
140 pages: Vec<String>,
141}
142
143impl PurgeTargets {
144 fn for_base(base: &str) -> Option<Self> {
145 let base = base.trim().trim_end_matches('/');
146 let host = base
147 .strip_prefix("https://")
148 .or_else(|| base.strip_prefix("http://"))?;
149 if host.is_empty() || host.contains('/') {
150 return None;
151 }
152 let join = |path: &str| format!("{base}{path}");
153 Some(Self {
154 prefixes: crate::ssr_cache::CACHEABLE_PREFIXES
155 .iter()
156 .map(|p| join(p))
157 .collect(),
158 pages: crate::ssr_cache::CACHEABLE_PAGES
159 .iter()
160 .map(|p| join(p))
161 .collect(),
162 })
163 }
164}
165
166async fn purge_with_retry(
167 client: &reqwest::Client,
168 credentials: &Credentials,
169 targets: &PurgeTargets,
170 pass: &'static str,
171) {
172 let tasks = [
173 (
174 "purge_prefix",
175 serde_json::json!({
176 "ZoneId": credentials.zone_id,
177 "Type": "purge_prefix",
178 "Method": "delete",
180 "Targets": targets.prefixes,
181 }),
182 ),
183 (
184 "purge_url",
185 serde_json::json!({
186 "ZoneId": credentials.zone_id,
187 "Type": "purge_url",
188 "Targets": targets.pages,
189 }),
190 ),
191 ];
192 for (kind, body) in tasks {
193 let payload = body.to_string();
194 let mut attempt = 0;
195 loop {
196 match create_purge_task(client, credentials, &payload).await {
197 Ok(job_id) => {
198 tracing::info!(pass, kind, %job_id, "已提交 EdgeOne HTML 缓存清除");
199 break;
200 }
201 Err(error) if attempt < RETRY_DELAYS.len() => {
202 tracing::warn!(pass, kind, attempt, %error, "EdgeOne 缓存清除失败,稍后重试");
203 tokio::time::sleep(RETRY_DELAYS[attempt]).await;
204 attempt += 1;
205 }
206 Err(error) => {
207 tracing::error!(
208 pass,
209 kind,
210 %error,
211 "EdgeOne 缓存清除最终失败,边缘 HTML 要等 s-maxage 到期才更新"
212 );
213 break;
214 }
215 }
216 }
217 }
218}
219
220async fn create_purge_task(
221 client: &reqwest::Client,
222 credentials: &Credentials,
223 payload: &str,
224) -> Result<String, String> {
225 let timestamp = chrono::Utc::now().timestamp();
226 let authorization = authorization(
227 &credentials.secret_id,
228 &credentials.secret_key,
229 timestamp,
230 payload,
231 );
232 let response = client
233 .post(format!("https://{API_HOST}/"))
234 .header("Content-Type", CONTENT_TYPE)
235 .header("X-TC-Action", API_ACTION)
236 .header("X-TC-Version", API_VERSION)
237 .header("X-TC-Timestamp", timestamp.to_string())
238 .header("Authorization", authorization)
239 .body(payload.to_owned())
240 .send()
241 .await
242 .map_err(|e| format!("请求失败:{e}"))?;
243 let status = response.status();
244 let text = response
245 .text()
246 .await
247 .map_err(|e| format!("读取响应失败:{e}"))?;
248 if !status.is_success() {
249 return Err(format!("HTTP {status}"));
250 }
251 parse_response(&text)
252}
253
254fn parse_response(text: &str) -> Result<String, String> {
256 let value: serde_json::Value =
257 serde_json::from_str(text).map_err(|_| "响应不是 JSON".to_owned())?;
258 let response = &value["Response"];
259 if let Some(error) = response.get("Error") {
260 return Err(format!(
261 "{}: {}",
262 error["Code"].as_str().unwrap_or("Unknown"),
263 error["Message"].as_str().unwrap_or("")
264 ));
265 }
266 if let Some(failed) = response["FailedList"].as_array().filter(|l| !l.is_empty()) {
267 let failed: Vec<String> = failed
268 .iter()
269 .map(|item| {
270 format!(
271 "{} ({})",
272 item["Target"].as_str().unwrap_or("?"),
273 item["Reason"].as_str().unwrap_or("?")
274 )
275 })
276 .collect();
277 return Err(format!("部分目标失败:{}", failed.join(", ")));
278 }
279 Ok(response["JobId"].as_str().unwrap_or("").to_owned())
280}
281
282fn authorization(secret_id: &str, secret_key: &str, timestamp: i64, payload: &str) -> String {
284 let date = chrono::DateTime::from_timestamp(timestamp, 0)
285 .unwrap_or_default()
286 .format("%Y-%m-%d")
287 .to_string();
288 let canonical_request = format!(
289 "POST\n/\n\ncontent-type:{CONTENT_TYPE}\nhost:{API_HOST}\n\ncontent-type;host\n{}",
290 hex::encode(Sha256::digest(payload.as_bytes()))
291 );
292 let scope = format!("{date}/{API_SERVICE}/tc3_request");
293 let string_to_sign = format!(
294 "TC3-HMAC-SHA256\n{timestamp}\n{scope}\n{}",
295 hex::encode(Sha256::digest(canonical_request.as_bytes()))
296 );
297 let key = hmac_sha256(format!("TC3{secret_key}").as_bytes(), date.as_bytes());
298 let key = hmac_sha256(&key, API_SERVICE.as_bytes());
299 let key = hmac_sha256(&key, b"tc3_request");
300 let signature = hex::encode(hmac_sha256(&key, string_to_sign.as_bytes()));
301 format!(
302 "TC3-HMAC-SHA256 Credential={secret_id}/{scope}, SignedHeaders=content-type;host, Signature={signature}"
303 )
304}
305
306fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec<u8> {
308 const BLOCK: usize = 64;
309 let mut block_key = [0u8; BLOCK];
310 if key.len() > BLOCK {
311 let digest = Sha256::digest(key);
312 block_key[..digest.len()].copy_from_slice(&digest);
313 } else {
314 block_key[..key.len()].copy_from_slice(key);
315 }
316 let mut inner = Sha256::new();
317 inner.update(block_key.map(|b| b ^ 0x36));
318 inner.update(message);
319 let inner = inner.finalize();
320 let mut outer = Sha256::new();
321 outer.update(block_key.map(|b| b ^ 0x5c));
322 outer.update(inner);
323 outer.finalize().to_vec()
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn hmac_matches_rfc_4231() {
332 assert_eq!(
334 hex::encode(hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
335 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
336 );
337 assert_eq!(
338 hex::encode(hmac_sha256(
339 &[0xaa; 131],
340 b"Test Using Larger Than Block-Size Key - Hash Key First"
341 )),
342 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
343 );
344 }
345
346 #[test]
347 fn signature_matches_tencentcloud_sdk() {
348 let payload =
350 r#"{"ZoneId":"zone-test","Type":"purge_url","Targets":["https://example.com/"]}"#;
351 assert_eq!(
352 authorization("AKIDtest", "test-secret-key", 1_762_761_600, payload),
353 "TC3-HMAC-SHA256 Credential=AKIDtest/2025-11-10/teo/tc3_request, \
354 SignedHeaders=content-type;host, \
355 Signature=c5074f1f2ae43492077ae5622538a500c6a5af8fc435886f2602c36218bf845b"
356 );
357 }
358
359 #[test]
360 fn targets_cover_the_ssr_cache_whitelist() {
361 let targets = PurgeTargets::for_base(" https://rua.plus/ ").unwrap();
362 assert_eq!(
363 targets.prefixes,
364 [
365 "https://rua.plus/post/",
366 "https://rua.plus/page/",
367 "https://rua.plus/tags/"
368 ]
369 );
370 assert_eq!(
371 targets.pages,
372 [
373 "https://rua.plus/",
374 "https://rua.plus/archives",
375 "https://rua.plus/friends",
376 "https://rua.plus/about",
377 "https://rua.plus/changelog"
378 ]
379 );
380 for base in [
381 "",
382 "rua.plus",
383 "ftp://rua.plus",
384 "https://",
385 "https://rua.plus/blog",
386 ] {
387 assert_eq!(PurgeTargets::for_base(base), None, "{base}");
388 }
389 }
390
391 #[test]
392 fn response_errors_and_failed_targets_are_failures() {
393 assert_eq!(
394 parse_response(r#"{"Response":{"JobId":"job-1","FailedList":[],"RequestId":"r"}}"#),
395 Ok("job-1".to_owned())
396 );
397 assert_eq!(
398 parse_response(
399 r#"{"Response":{"Error":{"Code":"AuthFailure.SignatureFailure","Message":"bad"},"RequestId":"r"}}"#
400 ),
401 Err("AuthFailure.SignatureFailure: bad".to_owned())
402 );
403 assert_eq!(
404 parse_response(
405 r#"{"Response":{"JobId":"job-2","FailedList":[{"Target":"https://rua.plus/","Reason":"quota"}]}}"#
406 ),
407 Err("部分目标失败:https://rua.plus/ (quota)".to_owned())
408 );
409 assert!(parse_response("<html>").is_err());
410 }
411}