1#![cfg(feature = "server")]
8
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, LazyLock, Mutex};
11use std::time::{Duration, Instant};
12
13use axum::body::{Body, BodyDataStream};
14use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri};
15use axum::response::Response;
16use bytes::Bytes;
17use futures::StreamExt;
18use sha2::{Digest, Sha256};
19
20use crate::compression::{self, CompressionAlgorithms, Effort, Encoding};
21
22const MAX_CACHE_BYTES: u64 = 32 * 1024 * 1024;
25const _: () = assert!(crate::models::settings::MAX_SSR_CACHE_MAX_PAGE_BYTES <= MAX_CACHE_BYTES);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct SsrGeneration(pub u64);
29
30#[derive(Clone)]
31struct CachedPage {
32 body: Bytes,
33 encoded: Arc<[(Encoding, Bytes)]>,
35 headers: HeaderMap,
36 created: Instant,
37 etag: HeaderValue,
38 generation: u64,
39}
40
41struct HtmlCache {
42 pages: moka::sync::Cache<String, CachedPage>,
43 generation: AtomicU64,
44 gate: Mutex<()>,
45 enabled: bool,
46 max_page_bytes: usize,
48 namespace: Mutex<String>,
50}
51
52impl HtmlCache {
53 fn new(ttl: Duration, capacity: u64, max_page_bytes: usize) -> Self {
54 Self {
55 pages: moka::sync::Cache::builder()
56 .max_capacity(capacity)
57 .weigher(|key: &String, page: &CachedPage| {
58 let encoded: usize = page.encoded.iter().map(|(_, bytes)| bytes.len()).sum();
59 (key.len() + page.body.len() + encoded + 1024) as u32
60 })
61 .time_to_live(ttl)
62 .build(),
63 generation: AtomicU64::new(0),
64 gate: Mutex::new(()),
65 enabled: !ttl.is_zero(),
66 max_page_bytes,
67 namespace: Mutex::new(String::new()),
68 }
69 }
70
71 fn generation(&self) -> u64 {
72 self.generation.load(Ordering::Acquire)
73 }
74
75 fn invalidate(&self) -> u64 {
76 let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
77 let generation = self
78 .generation
79 .fetch_add(1, Ordering::AcqRel)
80 .wrapping_add(1);
81 self.pages.invalidate_all();
82 generation
83 }
84
85 fn swap_namespace(&self, namespace: &str) -> bool {
90 let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
91 let mut current = self.namespace.lock().unwrap_or_else(|e| e.into_inner());
92 if *current == namespace {
93 return false;
94 }
95 *current = namespace.to_owned();
96 let _ = self
98 .generation
99 .fetch_add(1, Ordering::AcqRel)
100 .wrapping_add(1);
101 self.pages.invalidate_all();
102 true
103 }
104
105 fn get(&self, path: &str) -> Option<CachedPage> {
106 self.pages
107 .get(path)
108 .filter(|page| page.generation == self.generation())
109 }
110
111 fn insert(
114 &self,
115 path: String,
116 headers: HeaderMap,
117 body: Vec<u8>,
118 generation: u64,
119 ) -> Option<(HeaderValue, Bytes)> {
120 if !self.enabled || body.len() > self.max_page_bytes {
121 return None;
122 }
123 let etag = HeaderValue::from_str(&format!("\"{}\"", hex::encode(Sha256::digest(&body))))
124 .expect("SHA256 is an ASCII entity tag");
125 let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
126 if generation != self.generation()
127 || self
128 .pages
129 .get(&path)
130 .is_some_and(|page| page.generation == generation && page.etag == etag)
131 {
132 return None;
133 }
134 let body = Bytes::from(body);
135 self.pages.insert(
136 path,
137 CachedPage {
138 body: body.clone(),
139 encoded: Arc::new([]),
140 headers,
141 etag: etag.clone(),
142 generation,
143 created: Instant::now(),
144 },
145 );
146 Some((etag, body))
147 }
148
149 fn attach_encoded(
151 &self,
152 path: &str,
153 etag: &HeaderValue,
154 generation: u64,
155 encoded: Vec<(Encoding, Bytes)>,
156 ) {
157 let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
158 let Some(page) = self
159 .get(path)
160 .filter(|page| page.generation == generation && page.etag == *etag)
161 else {
162 return;
163 };
164 self.pages.insert(
165 path.to_owned(),
166 CachedPage {
167 encoded: encoded.into(),
168 ..page
169 },
170 );
171 }
172}
173
174fn precompress(path: String, etag: HeaderValue, generation: u64, body: Bytes) {
177 let Some(algorithms) = compression::algorithms() else {
178 return;
179 };
180 tokio::task::spawn_blocking(move || {
181 let mut encoded = Vec::new();
182 for encoding in algorithms.encodings() {
183 match compression::encode(encoding, Effort::Cached, &body) {
184 Some(Ok(bytes)) => encoded.push((encoding, Bytes::from(bytes))),
185 Some(Err(error)) => {
186 tracing::warn!(%error, %path, "SSR 缓存预压缩失败,命中继续现压");
187 return;
188 }
189 None => {}
190 }
191 }
192 HTML_CACHE.attach_encoded(&path, &etag, generation, encoded);
193 });
194}
195
196fn hit_response(
199 page: CachedPage,
200 request: &HeaderMap,
201 algorithms: Option<CompressionAlgorithms>,
202) -> Response {
203 let unchanged = request
204 .get(header::IF_NONE_MATCH)
205 .and_then(|h| h.to_str().ok())
206 .is_some_and(|value| {
207 value.split(',').any(|tag| {
208 let tag = tag.trim().strip_prefix("W/").unwrap_or(tag.trim());
209 tag == "*" || tag == page.etag.to_str().unwrap_or("")
210 })
211 });
212 let variant = algorithms
213 .and_then(|algorithms| compression::negotiate(request, algorithms))
214 .and_then(|encoding| page.encoded.iter().find(|(e, _)| *e == encoding).cloned())
215 .filter(|_| !unchanged);
216 let mut response = Response::new(match (&variant, unchanged) {
217 (_, true) => Body::empty(),
218 (Some((_, bytes)), false) => Body::from(bytes.clone()),
219 (None, false) => Body::from(page.body),
220 });
221 *response.headers_mut() = page.headers;
222 if unchanged {
223 *response.status_mut() = StatusCode::NOT_MODIFIED;
224 }
225 let headers = response.headers_mut();
226 if let Some((encoding, _)) = variant {
227 headers.insert(header::CONTENT_ENCODING, encoding.header_value());
228 }
229 if algorithms.is_some() {
230 headers.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
231 }
232 headers.insert(header::ETAG, page.etag);
233 headers.insert(
234 header::AGE,
235 HeaderValue::from(page.created.elapsed().as_secs()),
236 );
237 headers.insert("x-ssr-cache", HeaderValue::from_static("HIT"));
238 headers.insert(header::CACHE_CONTROL, public_page_cache_control());
239 response
240}
241
242static HTML_CACHE: LazyLock<HtmlCache> = LazyLock::new(|| {
243 HtmlCache::new(
245 Duration::from_secs(crate::utils::server::parse_ssr_cache_secs()),
246 MAX_CACHE_BYTES,
247 crate::config::ssr_cache().max_page_bytes as usize,
248 )
249});
250
251fn cacheable_request(method: &Method, uri: &Uri, headers: &HeaderMap) -> bool {
253 if method != Method::GET
254 || uri.query().is_some()
255 || headers.contains_key(header::COOKIE)
256 || headers.contains_key(header::AUTHORIZATION)
257 || uri.path().len() > 512
258 {
259 return false;
260 }
261 let path = uri.path();
262 CACHEABLE_PAGES.contains(&path)
263 || CACHEABLE_PREFIXES.iter().any(|prefix| {
264 path.strip_prefix(prefix)
265 .is_some_and(|tail| !tail.is_empty() && !tail.contains('/'))
266 })
267}
268
269pub(crate) const CACHEABLE_PAGES: [&str; 5] =
272 ["/", "/archives", "/friends", "/about", "/changelog"];
273pub(crate) const CACHEABLE_PREFIXES: [&str; 3] = ["/post/", "/page/", "/tags/"];
275
276fn public_page_cache_control() -> HeaderValue {
280 static VALUE: LazyLock<HeaderValue> =
281 LazyLock::new(|| edge_cache_control(crate::config::ssr_cache().edge_ttl_secs));
282 VALUE.clone()
283}
284
285fn edge_cache_control(edge_ttl_secs: u64) -> HeaderValue {
286 if edge_ttl_secs == 0 {
287 return HeaderValue::from_static("public, no-cache");
288 }
289 HeaderValue::from_str(&format!("public, max-age=0, s-maxage={edge_ttl_secs}"))
290 .expect("数字组成的 Cache-Control 是合法头部值")
291}
292
293fn cacheable_response(response: &Response) -> bool {
294 let headers = response.headers();
295 response.status() == StatusCode::OK
296 && headers
297 .get(header::CONTENT_TYPE)
298 .and_then(|v| v.to_str().ok())
299 .is_some_and(|v| v.starts_with("text/html"))
300 && !headers.contains_key(header::SET_COOKIE)
301 && !headers.contains_key(header::CONTENT_ENCODING)
302 && !headers.contains_key(header::VARY)
303 && !headers
304 .get(header::CACHE_CONTROL)
305 .and_then(|v| v.to_str().ok())
306 .is_some_and(|v| {
307 v.split(',')
308 .any(|p| matches!(p.trim(), "private" | "no-store"))
309 })
310}
311
312struct CaptureBody {
313 input: BodyDataStream,
314 buffer: Option<Vec<u8>>,
315 path: String,
316 headers: HeaderMap,
317 generation: u64,
318}
319
320pub async fn cache_public_html(
322 req: axum::extract::Request,
323 next: axum::middleware::Next,
324) -> Response {
325 let authenticated = req.headers().contains_key(header::COOKIE)
326 || req.headers().contains_key(header::AUTHORIZATION);
327 let cacheable = cacheable_request(req.method(), req.uri(), req.headers());
328 if !HTML_CACHE.enabled || !cacheable {
329 let mut response = next.run(req).await;
330 response
331 .headers_mut()
332 .insert("x-ssr-cache", HeaderValue::from_static("BYPASS"));
333 if cacheable && cacheable_response(&response) {
335 response
336 .headers_mut()
337 .insert(header::CACHE_CONTROL, public_page_cache_control());
338 }
339 if authenticated
340 && response
341 .headers()
342 .get(header::CONTENT_TYPE)
343 .and_then(|v| v.to_str().ok())
344 .is_some_and(|v| v.starts_with("text/html"))
345 {
346 response.headers_mut().insert(
347 header::CACHE_CONTROL,
348 HeaderValue::from_static("private, no-store"),
349 );
350 }
351 return response;
352 }
353 let path = req.uri().path().to_owned();
354 if let Some(page) = HTML_CACHE.get(&path) {
355 return hit_response(page, req.headers(), compression::algorithms());
356 }
357 let generation = HTML_CACHE.generation();
358 let mut response = next.run(req).await;
359 if !cacheable_response(&response) {
360 return response;
361 }
362 response
363 .headers_mut()
364 .insert("x-ssr-cache", HeaderValue::from_static("MISS"));
365 response
366 .headers_mut()
367 .insert(header::CACHE_CONTROL, public_page_cache_control());
368 let (parts, body) = response.into_parts();
369 let mut headers = parts.headers.clone();
370 for name in [
371 header::CONTENT_LENGTH,
372 header::TRANSFER_ENCODING,
373 header::AGE,
374 ] {
375 headers.remove(name);
376 }
377 let state = CaptureBody {
378 input: body.into_data_stream(),
379 buffer: Some(Vec::new()),
380 path,
381 headers,
382 generation,
383 };
384 let stream = futures::stream::unfold(state, |mut state| async move {
385 match state.input.next().await {
386 Some(Ok(chunk)) => {
387 if let Some(buffer) = state.buffer.as_mut() {
388 if buffer.len() + chunk.len() <= HTML_CACHE.max_page_bytes {
389 buffer.extend_from_slice(&chunk);
390 } else {
391 state.buffer = None;
392 }
393 }
394 Some((Ok::<_, axum::Error>(chunk), state))
395 }
396 Some(Err(error)) => {
397 state.buffer = None;
398 Some((Err(error), state))
399 }
400 None => {
401 if let Some(buffer) = state.buffer.take() {
402 if let Some((etag, body)) = HTML_CACHE.insert(
403 state.path.clone(),
404 state.headers,
405 buffer,
406 state.generation,
407 ) {
408 precompress(state.path, etag, state.generation, body);
409 }
410 }
411 None
412 }
413 }
414 })
415 .fuse();
417 Response::from_parts(parts, Body::from_stream(stream))
418}
419
420pub fn invalidate_ssr_route(_route: &str) {
423 HTML_CACHE.invalidate();
424 crate::tasks::edge_purge::request_purge();
425}
426pub fn invalidate_post_preview(_slug: &str) { }
428pub fn invalidate_ssr_all_public() {
429 HTML_CACHE.invalidate();
430 crate::tasks::edge_purge::request_purge();
431}
432pub fn set_release_namespace(namespace: &str) {
435 if HTML_CACHE.swap_namespace(namespace) {
436 tracing::info!(%namespace, "SSR 缓存命名空间切换,旧 release/flavor 页面已失效");
437 crate::tasks::edge_purge::request_purge();
438 }
439}
440pub fn bump_global_generation() -> u64 {
441 let generation = HTML_CACHE.invalidate();
442 crate::tasks::edge_purge::request_purge();
443 generation
444}
445pub fn current_global_generation() -> u64 {
446 HTML_CACHE.generation()
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn cache_excludes_auth_private_routes_and_query_strings() {
455 let mut headers = HeaderMap::new();
456 for path in [
457 "/admin",
458 "/login",
459 "/notes",
460 "/notes/example",
461 "/note-media/1",
462 "/api/test",
463 "/?page=1",
464 "/post/a?preview=1",
465 ] {
466 assert!(
467 !cacheable_request(&Method::GET, &path.parse().unwrap(), &headers),
468 "{path}"
469 );
470 }
471 assert!(cacheable_request(
472 &Method::GET,
473 &"/post/a".parse().unwrap(),
474 &headers
475 ));
476 headers.insert(header::COOKIE, HeaderValue::from_static("session=private"));
477 assert!(!cacheable_request(
478 &Method::GET,
479 &"/".parse().unwrap(),
480 &headers
481 ));
482 headers.clear();
483 headers.insert(
484 header::AUTHORIZATION,
485 HeaderValue::from_static("Bearer private"),
486 );
487 assert!(!cacheable_request(
488 &Method::GET,
489 &"/".parse().unwrap(),
490 &headers
491 ));
492 }
493
494 #[test]
495 fn edge_ttl_controls_shared_cache_only() {
496 assert_eq!(edge_cache_control(0), "public, no-cache");
497 assert_eq!(edge_cache_control(3600), "public, max-age=0, s-maxage=3600");
500 assert_eq!(public_page_cache_control(), "public, no-cache");
502 }
503
504 #[test]
505 fn invalidation_rejects_inflight_old_responses() {
506 let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1024);
507 let generation = cache.generation();
508 cache.insert("/".into(), HeaderMap::new(), b"old".to_vec(), generation);
509 assert!(cache.get("/").is_some());
510 cache.invalidate();
511 cache.insert(
512 "/".into(),
513 HeaderMap::new(),
514 b"stale inflight".to_vec(),
515 generation,
516 );
517 assert!(cache.get("/").is_none());
518 cache.insert(
519 "/".into(),
520 HeaderMap::new(),
521 b"new".to_vec(),
522 cache.generation(),
523 );
524 assert_eq!(cache.get("/").unwrap().body.as_ref(), b"new");
525 }
526
527 #[test]
528 fn namespace_swap_isolates_release_and_flavor_variants() {
529 let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1024);
530 let generation = cache.generation();
531 cache.insert(
532 "/post/a".into(),
533 HeaderMap::new(),
534 b"dual page".to_vec(),
535 generation,
536 );
537 assert!(cache.get("/post/a").is_some());
538
539 assert!(cache.swap_namespace("full/rel-2"));
541 assert!(cache.get("/post/a").is_none());
542 cache.insert(
544 "/post/a".into(),
545 HeaderMap::new(),
546 b"stale dual page".to_vec(),
547 generation,
548 );
549 assert!(cache.get("/post/a").is_none());
550
551 cache.insert(
553 "/post/a".into(),
554 HeaderMap::new(),
555 b"full page".to_vec(),
556 cache.generation(),
557 );
558 assert_eq!(cache.get("/post/a").unwrap().body.as_ref(), b"full page");
559
560 assert!(!cache.swap_namespace("full/rel-2"));
562 assert_eq!(cache.get("/post/a").unwrap().body.as_ref(), b"full page");
563 }
564
565 #[test]
566 fn cached_pages_expire_and_oversize_pages_are_not_stored() {
567 let cache = HtmlCache::new(Duration::from_millis(20), MAX_CACHE_BYTES, 1024);
568 cache.insert("/".into(), HeaderMap::new(), b"html".to_vec(), 0);
569 assert!(cache.get("/").is_some());
570 std::thread::sleep(Duration::from_millis(40));
571 assert!(cache.get("/").is_none());
572 cache.insert("/limit".into(), HeaderMap::new(), vec![0; 1024], 0);
573 assert!(cache.get("/limit").is_some());
574 cache.insert("/large".into(), HeaderMap::new(), vec![0; 1025], 0);
575 assert!(cache.get("/large").is_none());
576 }
577
578 #[test]
579 fn sensitive_and_failed_responses_are_not_cached() {
580 let mut response = Response::builder()
581 .header(header::CONTENT_TYPE, "text/html")
582 .body(Body::empty())
583 .unwrap();
584 assert!(cacheable_response(&response));
585 response.headers_mut().insert(
586 header::SET_COOKIE,
587 HeaderValue::from_static("session=private"),
588 );
589 assert!(!cacheable_response(&response));
590 response.headers_mut().remove(header::SET_COOKIE);
591 *response.status_mut() = StatusCode::NOT_FOUND;
592 assert!(!cacheable_response(&response));
593 }
594
595 fn request(accept_encoding: &str) -> HeaderMap {
596 let mut headers = HeaderMap::new();
597 headers.insert(
598 header::ACCEPT_ENCODING,
599 HeaderValue::from_str(accept_encoding).unwrap(),
600 );
601 headers
602 }
603
604 async fn served(response: Response) -> (Option<Encoding>, Vec<u8>) {
605 let encoding = response
606 .headers()
607 .get(header::CONTENT_ENCODING)
608 .map(|value| match value.to_str().unwrap() {
609 "br" => Encoding::Brotli,
610 "gzip" => Encoding::Gzip,
611 other => panic!("unexpected encoding {other}"),
612 });
613 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
614 .await
615 .unwrap();
616 let html = compression::decode(encoding.unwrap_or(Encoding::Identity), &body);
617 (encoding, html)
618 }
619
620 #[tokio::test]
621 async fn hits_serve_negotiated_precompressed_variants() {
622 let algorithms = compression::parse_compression_algorithms("br, gzip");
623 let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1 << 20);
624 let html = "<article><p>预压缩命中 precompressed hit.</p></article>".repeat(500);
625 let (etag, body) = cache
626 .insert("/".into(), HeaderMap::new(), html.clone().into_bytes(), 0)
627 .unwrap();
628
629 let response = hit_response(cache.get("/").unwrap(), &request("br"), algorithms);
631 assert_eq!(response.headers()[header::VARY], "accept-encoding");
632 assert_eq!(served(response).await, (None, html.clone().into_bytes()));
633
634 let encoded = algorithms
635 .unwrap()
636 .encodings()
637 .map(|encoding| {
638 let bytes = compression::encode(encoding, Effort::Cached, &body);
639 (encoding, Bytes::from(bytes.unwrap().unwrap()))
640 })
641 .collect();
642 cache.attach_encoded("/", &etag, 0, encoded);
643 for (accept, expected) in [
644 ("gzip, deflate, br, zstd", Some(Encoding::Brotli)),
645 ("gzip", Some(Encoding::Gzip)),
646 ("zstd", None),
647 ] {
648 let response = hit_response(cache.get("/").unwrap(), &request(accept), algorithms);
649 assert_eq!(response.headers()[header::ETAG], etag, "{accept}");
650 assert_eq!(
651 served(response).await,
652 (expected, html.clone().into_bytes()),
653 "{accept}"
654 );
655 }
656
657 let mut revalidate = request("br");
658 revalidate.insert(header::IF_NONE_MATCH, etag);
659 let response = hit_response(cache.get("/").unwrap(), &revalidate, algorithms);
660 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
661 assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
662 assert_eq!(served(response).await, (None, Vec::new()));
663
664 let response = hit_response(cache.get("/").unwrap(), &request("br"), None);
666 assert!(!response.headers().contains_key(header::VARY));
667 assert_eq!(served(response).await, (None, html.into_bytes()));
668 }
669
670 #[test]
671 fn variants_only_attach_to_the_page_they_were_encoded_from() {
672 let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1024);
673 let variant = || vec![(Encoding::Brotli, Bytes::from_static(b"br"))];
674 let (old_etag, _) = cache
675 .insert("/".into(), HeaderMap::new(), b"old".to_vec(), 0)
676 .unwrap();
677 cache.attach_encoded("/", &old_etag, 0, variant());
679 assert!(cache
680 .insert("/".into(), HeaderMap::new(), b"old".to_vec(), 0)
681 .is_none());
682 assert_eq!(cache.get("/").unwrap().encoded.len(), 1);
683
684 assert!(cache
686 .insert("/".into(), HeaderMap::new(), b"new".to_vec(), 0)
687 .is_some());
688 cache.attach_encoded("/", &old_etag, 0, variant());
689 assert!(cache.get("/").unwrap().encoded.is_empty());
690
691 let generation = cache.invalidate();
693 let (etag, _) = cache
694 .insert("/".into(), HeaderMap::new(), b"new".to_vec(), generation)
695 .unwrap();
696 cache.invalidate();
697 cache.attach_encoded("/", &etag, generation, variant());
698 assert!(cache.get("/").is_none());
699 }
700}