1#![cfg(feature = "server")]
10
11const PKG_ENTRY_PREFIX: &str = "/pkg/yggdrasil.";
13
14fn is_hashed_pkg_file(path: &str) -> bool {
16 let Some(file_name) = path.rsplit('/').next() else {
17 return false;
18 };
19 let parts: Vec<&str> = file_name.split('.').collect();
20 if parts.len() < 3 || parts[0].is_empty() || parts[parts.len() - 1].is_empty() {
21 return false;
22 }
23 let hash = parts[parts.len() - 2];
24 hash.len() == 22
25 && hash
26 .bytes()
27 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
28}
29
30fn is_hashed_doc_file(path: &str) -> bool {
37 let is_hex = |s: &str, len: std::ops::RangeInclusive<usize>| {
38 len.contains(&s.len()) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
39 };
40 if let Some(file) = path.strip_prefix("/doc/static.files/") {
41 return !file.contains('/')
42 && file
43 .split_once('.')
44 .and_then(|(stem, _)| stem.rsplit_once('-'))
45 .is_some_and(|(name, hash)| !name.is_empty() && is_hex(hash, 8..=8));
46 }
47 if let Some(rest) = path.strip_prefix("/doc/search.index/") {
48 return rest
49 .split_once('/')
50 .and_then(|(column, file)| Some((column, file.strip_suffix(".js")?)))
51 .is_some_and(|(column, hash)| !column.is_empty() && is_hex(hash, 8..=64));
52 }
53 false
54}
55
56const IMMUTABLE: &str = "public, max-age=31536000, immutable";
57const PUBLIC_DEFAULT: &str = "public, max-age=300, stale-while-revalidate=3600";
59
60pub(crate) fn cache_control_for_path(
66 path: &str,
67 query: Option<&str>,
68 method: &axum::http::Method,
69 entry_content_addressed: bool,
70) -> Option<axum::http::HeaderValue> {
71 use axum::http::{HeaderValue, Method};
72
73 if *method != Method::GET && *method != Method::HEAD {
75 return None;
76 }
77
78 if path == "/notes" || path.starts_with("/notes/") {
80 return Some(HeaderValue::from_static("no-store"));
81 }
82 if path.starts_with("/admin/notes")
83 || path == "/admin/notebooks"
84 || path.starts_with("/note-media/")
85 {
86 return Some(HeaderValue::from_static("private, no-store"));
87 }
88 if path.starts_with("/api") {
89 return None;
90 }
91
92 if crate::routes::is_admin_path(path) || path == "/login" || path == "/register" {
94 return None;
95 }
96
97 if path.starts_with("/pkg/") {
105 let immutable = is_hashed_pkg_file(path)
106 && (entry_content_addressed || !path.starts_with(PKG_ENTRY_PREFIX));
107 return Some(HeaderValue::from_static(if immutable {
108 IMMUTABLE
109 } else {
110 "public, no-cache"
111 }));
112 }
113
114 if is_hashed_doc_file(path) {
117 return Some(HeaderValue::from_static(IMMUTABLE));
118 }
119
120 if path.ends_with(".wasm") || path.ends_with(".js") || path.ends_with(".css") {
123 return Some(HeaderValue::from_static(
124 if crate::static_assets::is_current(path, query) {
125 IMMUTABLE
126 } else {
127 "public, no-cache"
128 },
129 ));
130 }
131
132 Some(HeaderValue::from_static(PUBLIC_DEFAULT))
135}
136
137fn is_dynamic_html(path: &str, response: &axum::response::Response) -> bool {
142 !path.starts_with("/doc/")
143 && response
144 .headers()
145 .get(axum::http::header::CONTENT_TYPE)
146 .and_then(|v| v.to_str().ok())
147 .is_some_and(|v| v.starts_with("text/html"))
148}
149
150pub(crate) async fn add_cache_control(
152 entry_content_addressed: bool,
153 req: axum::extract::Request,
154 next: axum::middleware::Next,
155) -> axum::response::Response {
156 use axum::http::header;
157
158 let path = req.uri().path().to_string();
159 let cache_value = cache_control_for_path(
160 &path,
161 req.uri().query(),
162 req.method(),
163 entry_content_addressed,
164 );
165
166 let mut response = next.run(req).await;
167
168 let cacheable_status =
171 response.status().is_success() || response.status() == axum::http::StatusCode::NOT_MODIFIED;
172 let cache_value = cache_value.map(|value| {
173 if (!cacheable_status && value == IMMUTABLE)
174 || (value == PUBLIC_DEFAULT && is_dynamic_html(&path, &response))
175 {
176 axum::http::HeaderValue::from_static("public, no-cache")
177 } else {
178 value
179 }
180 });
181
182 if let Some(value) = cache_value {
183 response
185 .headers_mut()
186 .entry(header::CACHE_CONTROL)
187 .or_insert(value);
188 }
189
190 response
191}
192
193const PANIC_RESPONSE_BODY: &str = "服务器内部错误";
195
196pub(crate) fn catch_panic_layer() -> tower_http::catch_panic::CatchPanicLayer<
198 fn(Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response,
199> {
200 tower_http::catch_panic::CatchPanicLayer::custom(panic_response)
201}
202
203fn panic_response(err: Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response {
204 use axum::response::IntoResponse;
205
206 let message = err
207 .downcast_ref::<String>()
208 .map(String::as_str)
209 .or_else(|| err.downcast_ref::<&str>().copied())
210 .unwrap_or("未知 panic 负载");
211 tracing::error!(panic = message, "请求处理 panic,返回 500");
212 (
215 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
216 [
217 (axum::http::header::CACHE_CONTROL, "no-store"),
218 (
219 axum::http::header::CONTENT_TYPE,
220 "text/plain; charset=utf-8",
221 ),
222 ],
223 PANIC_RESPONSE_BODY,
224 )
225 .into_response()
226}
227
228pub(crate) async fn admin_guard(
243 req: axum::extract::Request,
244 next: axum::middleware::Next,
245) -> axum::response::Response {
246 use axum::body::Body;
247 use axum::http::{header, StatusCode};
248 use axum::response::Response;
249
250 let path = req.uri().path().to_string();
251 if !crate::routes::is_admin_path(&path) {
252 return next.run(req).await;
253 }
254
255 let cookie = req
257 .headers()
258 .get("cookie")
259 .and_then(|h| h.to_str().ok())
260 .unwrap_or("");
261 let token = crate::auth::session::parse_session_token(cookie);
262
263 let session = match token {
264 Some(t) => Some(
265 crate::api::auth::get_user_by_token(t)
266 .await
267 .map(|user| user.map(|u| u.role)),
268 ),
269 None => None,
270 };
271
272 if admin_guard_allows(session) {
273 next.run(req).await
274 } else {
275 Response::builder()
276 .status(StatusCode::FOUND)
277 .header(header::LOCATION, "/login")
278 .body(Body::empty())
279 .expect("静态 302 重定向响应(合法 status + 固定 header + 空 body)必然构造成功")
280 }
281}
282
283fn admin_guard_allows<E>(
288 session: Option<Result<Option<crate::models::user::UserRole>, E>>,
289) -> bool {
290 use crate::models::user::UserRole;
291 match session {
292 Some(Ok(Some(role))) => role == UserRole::Admin,
293 Some(Err(_)) => true,
295 None | Some(Ok(None)) => false,
297 }
298}
299
300pub(crate) async fn ssr_generation_middleware(
303 req: axum::extract::Request,
304 next: axum::middleware::Next,
305) -> axum::response::Response {
306 let generation = crate::ssr_cache::current_global_generation();
307 let is_get = req.method() == axum::http::Method::GET;
308 let (mut parts, body) = req.into_parts();
309 parts
310 .extensions
311 .insert(crate::ssr_cache::SsrGeneration(generation));
312 let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
313 if is_get {
314 response.headers_mut().insert(
315 axum::http::header::HeaderName::from_static("x-ssr-generation"),
316 axum::http::HeaderValue::from_str(&generation.to_string())
317 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")),
318 );
319 }
320 response
321}
322
323pub(crate) async fn share_context_middleware(
327 mut req: axum::extract::Request,
328 next: axum::middleware::Next,
329) -> axum::response::Response {
330 if req.method() == axum::http::Method::GET {
331 let ctx = crate::api::og::share_context().await;
332 req.extensions_mut().insert(ctx);
333 }
334 next.run(req).await
335}
336
337pub(crate) async fn version_headers_middleware(
344 req: axum::extract::Request,
345 next: axum::middleware::Next,
346) -> axum::response::Response {
347 let mut response = next.run(req).await;
348 let h = response.headers_mut();
349 h.insert(
350 axum::http::header::SERVER,
351 axum::http::HeaderValue::from_str(&format!(
352 "yggdrasil/{}",
353 crate::build_info::BUILD_INFO.version
354 ))
355 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")),
356 );
357 h.insert(
358 axum::http::header::HeaderName::from_static("x-yggdrasil-version"),
359 axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version),
360 );
361 h.insert(
362 axum::http::header::HeaderName::from_static("x-yggdrasil-git"),
363 axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe)
364 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
365 );
366 h.insert(
367 axum::http::header::HeaderName::from_static("x-yggdrasil-hash"),
368 axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_hash)
369 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
370 );
371 response
372}
373
374fn previous_asset_file(previous_root: &std::path::Path, path: &str) -> Option<std::path::PathBuf> {
378 use std::path::Component;
379 use std::path::Path;
380
381 let rel = path.strip_prefix('/')?;
382 let top = rel.split('/').next()?;
383 if top != "pkg" {
384 return None;
385 }
386 let rel_path = Path::new(rel);
387 if rel_path
388 .components()
389 .any(|component| !matches!(component, Component::Normal(_)))
390 {
391 return None;
392 }
393 let file = previous_root.join(rel_path);
394 file.is_file().then_some(file)
395}
396
397pub(crate) async fn previous_release_assets(
409 previous_root: std::path::PathBuf,
410 req: axum::extract::Request,
411 next: axum::middleware::Next,
412) -> axum::response::Response {
413 use tower::ServiceExt;
414
415 let is_get = req.method() == axum::http::Method::GET;
416 let fallback = if is_get {
417 previous_asset_file(&previous_root, req.uri().path())
418 } else {
419 None
420 };
421 let (parts, body) = req.into_parts();
422 let response = next
423 .run(axum::http::Request::from_parts(parts.clone(), body))
424 .await;
425 let Some(file) = fallback else {
426 return response;
427 };
428 if response.status() != axum::http::StatusCode::NOT_FOUND {
429 return response;
430 }
431 let request = {
432 let mut builder = axum::http::Request::builder()
433 .method(axum::http::Method::GET)
434 .uri(parts.uri.clone());
435 for (name, value) in parts.headers.iter() {
436 builder = builder.header(name, value);
437 }
438 match builder.body(axum::body::Body::empty()) {
439 Ok(request) => request,
440 Err(_) => return response,
441 }
442 };
443 let served = tower_http::services::ServeFile::new(&file)
444 .precompressed_br()
445 .oneshot(request)
446 .await;
447 match served {
448 Ok(asset) if asset.status() == axum::http::StatusCode::OK => {
449 let mut asset = asset.map(axum::body::Body::new);
450 let headers = asset.headers_mut();
451 headers.insert(
452 axum::http::header::CACHE_CONTROL,
453 axum::http::HeaderValue::from_static(IMMUTABLE),
454 );
455 headers.insert(
456 axum::http::HeaderName::from_static("x-previous-release"),
457 axum::http::HeaderValue::from_static("1"),
458 );
459 tracing::debug!(file = %file.display(), "旧发布资源经保留窗口返回");
460 asset
461 }
462 _ => response,
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::{cache_control_for_path, previous_asset_file};
470 use axum::http::{header, Method, Request};
471 use tower::ServiceExt;
472
473 #[test]
474 fn admin_guard_redirects_invalid_session_and_fails_open_only_on_db_error() {
475 use super::admin_guard_allows;
476 use crate::models::user::UserRole;
477
478 assert!(!admin_guard_allows::<()>(None));
480 assert!(!admin_guard_allows::<()>(Some(Ok(None))));
481 assert!(!admin_guard_allows::<()>(Some(Ok(Some(UserRole::Blocked)))));
482 assert!(admin_guard_allows::<()>(Some(Ok(Some(UserRole::Admin)))));
484 let db_error: Result<Option<UserRole>, &str> = Err("db down");
486 assert!(admin_guard_allows(Some(db_error)));
487 }
488
489 #[test]
490 fn previous_asset_window_only_serves_hashed_dirs_inside_root() {
491 let root = std::env::temp_dir().join("ygg-prev-asset-test");
492 let pkg = root.join("pkg");
493 std::fs::create_dir_all(&pkg).unwrap();
494 std::fs::write(
495 pkg.join("legacy_chunk.AbCdEfGhIjKlMnOpQrStUv.wasm"),
496 b"wasm",
497 )
498 .unwrap();
499 std::fs::create_dir_all(root.join("uploads")).unwrap();
500 std::fs::write(root.join("uploads").join("secret.txt"), b"secret").unwrap();
501
502 let get = |path: &str| previous_asset_file(&root, path);
503 assert_eq!(
504 get("/pkg/legacy_chunk.AbCdEfGhIjKlMnOpQrStUv.wasm"),
505 Some(pkg.join("legacy_chunk.AbCdEfGhIjKlMnOpQrStUv.wasm"))
506 );
507 assert_eq!(get("/pkg/missing.js"), None);
510 assert_eq!(get("/pkg/"), None);
511 assert_eq!(
512 get("/assets/legacy_chunk.AbCdEfGhIjKlMnOpQrStUv.wasm"),
513 None
514 );
515 assert_eq!(get("/uploads/secret.txt"), None);
516 assert_eq!(get("/api/posts"), None);
517 assert_eq!(get("/pkg/../uploads/secret.txt"), None);
518 assert_eq!(get("/pkg/%2e%2e/uploads/secret.txt"), None);
519 std::fs::remove_dir_all(&root).ok();
520 }
521
522 fn cache_value(path: &str, method: Method) -> Option<String> {
523 cache_control_for_path(path, None, &method, false).map(|v| v.to_str().unwrap().to_string())
524 }
525
526 #[test]
527 fn public_page_is_cached() {
528 assert_eq!(
529 cache_value("/", Method::GET),
530 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
531 );
532 assert_eq!(
533 cache_value("/post/hello-world", Method::GET),
534 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
535 );
536 assert_eq!(
537 cache_value("/tags/rust", Method::GET),
538 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
539 );
540 }
541
542 #[test]
543 fn revocable_notes_never_use_browser_or_cdn_cache() {
544 for path in ["/notes", "/notes/example", "/notes/book/1"] {
545 for method in [Method::GET, Method::HEAD] {
546 assert_eq!(cache_value(path, method).as_deref(), Some("no-store"));
547 }
548 }
549 assert_eq!(
550 cache_value("/admin/notes/edit/1", Method::GET).as_deref(),
551 Some("private, no-store")
552 );
553 assert_eq!(
554 cache_value("/note-media/example", Method::GET).as_deref(),
555 Some("private, no-store")
556 );
557 }
558
559 #[test]
560 fn unversioned_assets_require_revalidation() {
561 for path in [
562 "/style.css",
563 "/highlight.css",
564 "/tiptap/editor.css",
565 "/xterm/terminal.css",
566 "/tiptap/editor.js",
567 "/codemirror/editor.js",
568 "/yggdrasil-core/yggdrasil-core.js",
569 "/yggdrasil-core/yggdrasil-enhance.js",
570 "/pkg/yggdrasil.wasm",
571 "/pkg/yggdrasil.js",
572 ] {
573 for method in [Method::GET, Method::HEAD] {
574 assert_eq!(
575 cache_value(path, method).as_deref(),
576 Some("public, no-cache"),
577 "{path}"
578 );
579 }
580 }
581 }
582
583 #[test]
584 #[serial_test::serial]
585 fn only_current_asset_version_is_immutable() {
586 let root = std::env::temp_dir().join(format!("ygg-cache-control-{}", std::process::id()));
587 std::fs::create_dir_all(root.join("yggdrasil-core")).unwrap();
588 std::fs::write(root.join("yggdrasil-core/yggdrasil-core.js"), "1").unwrap();
589 let previous = std::env::var_os("LEPTOS_SITE_ROOT");
590 std::env::set_var("LEPTOS_SITE_ROOT", &root);
591
592 let path = crate::static_assets::CORE_JS;
593 let url = crate::static_assets::url(path);
594 let (_, current) = url.split_once('?').unwrap();
595 let value = |query: Option<&str>| {
596 cache_control_for_path(path, query, &Method::GET, false)
597 .map(|v| v.to_str().unwrap().to_owned())
598 };
599 assert_eq!(
600 value(Some(current)).as_deref(),
601 Some("public, max-age=31536000, immutable")
602 );
603 assert_eq!(
605 value(Some("v=0123456789abcdef")).as_deref(),
606 Some("public, no-cache")
607 );
608 assert_eq!(value(None).as_deref(), Some("public, no-cache"));
609
610 match previous {
611 Some(value) => std::env::set_var("LEPTOS_SITE_ROOT", value),
612 None => std::env::remove_var("LEPTOS_SITE_ROOT"),
613 }
614 std::fs::remove_dir_all(&root).unwrap();
615 }
616
617 #[test]
618 fn hashed_pkg_assets_are_immutable() {
619 for path in [
620 "/pkg/__wasm_split.AbCdEfGhIjKlMnOpQrStUv.js",
621 "/pkg/chunk_12.AbCdEfGhIjKlMnOpQrStUv.wasm",
622 "/pkg/split___home_route_view_42.A-_dEfGhIjKlMnOpQrStU0.wasm",
623 ] {
624 for method in [Method::GET, Method::HEAD] {
625 assert_eq!(
626 cache_value(path, method).as_deref(),
627 Some("public, max-age=31536000, immutable"),
628 "{path}"
629 );
630 }
631 }
632 for path in [
635 "/pkg/yggdrasil.AbCdEfGhIjKlMnOpQrStUv.wasm",
636 "/pkg/yggdrasil.AbCdEfGhIjKlMnOpQrStUv.js",
637 "/pkg/chunk_12.short.wasm",
638 "/pkg/chunk_12.AbCdEfGhIjKlMnOpQrStUvX.wasm",
639 "/pkg/chunk_12.AbCdEfGhIjKlMnOpQrSt!v.wasm",
640 "/pkg/__wasm_split_manifest.json",
641 ] {
642 assert_eq!(
643 cache_value(path, Method::GET).as_deref(),
644 Some("public, no-cache"),
645 "{path}"
646 );
647 }
648 }
649
650 #[test]
651 fn content_addressed_entries_are_immutable() {
652 let value = |path: &str| {
653 cache_control_for_path(path, None, &Method::GET, true)
654 .map(|v| v.to_str().unwrap().to_owned())
655 };
656 for path in [
657 "/pkg/yggdrasil.AbCdEfGhIjKlMnOpQrStUv.wasm",
658 "/pkg/yggdrasil.AbCdEfGhIjKlMnOpQrStUv.js",
659 "/pkg/chunk_12.AbCdEfGhIjKlMnOpQrStUv.wasm",
660 ] {
661 assert_eq!(
662 value(path).as_deref(),
663 Some("public, max-age=31536000, immutable"),
664 "{path}"
665 );
666 }
667 for path in ["/pkg/yggdrasil.wasm", "/pkg/__wasm_split_manifest.json"] {
669 assert_eq!(value(path).as_deref(), Some("public, no-cache"), "{path}");
670 }
671 }
672
673 #[test]
674 fn api_and_admin_and_auth_are_not_cached() {
675 assert_eq!(cache_value("/api/posts", Method::GET), None);
676 assert_eq!(cache_value("/admin", Method::GET), None);
677 assert_eq!(cache_value("/admin/posts", Method::GET), None);
678 assert_eq!(cache_value("/login", Method::GET), None);
679 assert_eq!(cache_value("/register", Method::GET), None);
680 assert_eq!(
682 cache_value("/admin.css", Method::GET).as_deref(),
683 Some("public, no-cache")
684 );
685 }
686
687 #[test]
688 fn non_get_requests_are_not_cached() {
689 assert_eq!(cache_value("/", Method::POST), None);
690 assert_eq!(cache_value("/post/hello-world", Method::POST), None);
691 assert_eq!(cache_value("/style.css", Method::POST), None);
692 }
693
694 #[test]
695 fn head_requests_are_cached_like_get() {
696 assert_eq!(
697 cache_value("/", Method::HEAD),
698 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
699 );
700 }
701
702 #[test]
703 fn hashed_rustdoc_files_are_immutable() {
704 for path in [
705 "/doc/static.files/rustdoc-0c87338a.css",
706 "/doc/static.files/main-417ebf0a.js",
707 "/doc/static.files/FiraSans-Regular-0fe48ade.woff2",
708 "/doc/static.files/SourceSerif4-Regular-6b053e98.ttf.woff2",
709 "/doc/search.index/type/c76a8883ca.js",
710 "/doc/search.index/crateNames/69ed7a0c47.js",
711 ] {
712 assert_eq!(
713 cache_value(path, Method::GET).as_deref(),
714 Some("public, max-age=31536000, immutable"),
715 "{path}"
716 );
717 }
718 for path in [
720 "/doc/search.index/root.js",
721 "/doc/doc-shell.js",
722 "/doc/theme.css",
723 "/doc/static.files/rustdoc.css",
724 "/doc/static.files/rustdoc-0C87338A.css",
725 "/doc/static.files/sub/rustdoc-0c87338a.css",
726 "/doc/search.index/type/latest.js",
727 ] {
728 assert_eq!(
729 cache_value(path, Method::GET).as_deref(),
730 Some("public, no-cache"),
731 "{path}"
732 );
733 }
734 assert_eq!(
735 cache_value("/doc/yggdrasil/index.html", Method::GET).as_deref(),
736 Some("public, max-age=300, stale-while-revalidate=3600")
737 );
738 }
739
740 #[tokio::test]
741 async fn missing_hashed_file_is_not_cached_as_immutable() {
742 let app = axum::Router::new()
743 .route(
744 "/doc/static.files/rustdoc-0c87338a.css",
745 axum::routing::get(|| async { "css" }),
746 )
747 .layer(axum::middleware::from_fn(|req, next| {
748 super::add_cache_control(false, req, next)
749 }));
750 let get = |path: &'static str| {
751 let app = app.clone();
752 async move {
753 app.oneshot(Request::get(path).body(axum::body::Body::empty()).unwrap())
754 .await
755 .unwrap()
756 }
757 };
758
759 let found = get("/doc/static.files/rustdoc-0c87338a.css").await;
760 assert_eq!(
761 found.headers()[header::CACHE_CONTROL],
762 "public, max-age=31536000, immutable"
763 );
764 for path in [
765 "/doc/static.files/rustdoc-deadbeef.css",
766 "/pkg/chunk_1.AbCdEfGhIjKlMnOpQrStUv.wasm",
767 ] {
768 let missing = get(path).await;
769 assert_eq!(missing.status(), axum::http::StatusCode::NOT_FOUND);
770 assert_eq!(missing.headers()[header::CACHE_CONTROL], "public, no-cache");
771 }
772 }
773
774 #[tokio::test]
775 async fn dynamic_html_is_never_shared_cached_by_default() {
776 fn html() -> axum::response::Response {
777 use axum::response::IntoResponse;
778 axum::response::Html("<p>page</p>").into_response()
779 }
780 let app = axum::Router::new()
781 .route("/", axum::routing::get(|| async { html() }))
782 .route("/search", axum::routing::get(|| async { html() }))
783 .route(
784 "/preset",
785 axum::routing::get(|| async {
786 (
787 [(header::CACHE_CONTROL, "public, max-age=0, s-maxage=60")],
788 html(),
789 )
790 }),
791 )
792 .route(
793 "/doc/yggdrasil/index.html",
794 axum::routing::get(|| async { html() }),
795 )
796 .route("/favicon.svg", axum::routing::get(|| async { "<svg/>" }))
797 .layer(axum::middleware::from_fn(|req, next| {
798 super::add_cache_control(false, req, next)
799 }));
800 let cache_control = |uri: &'static str| {
801 let app = app.clone();
802 async move {
803 let response = app
804 .oneshot(Request::get(uri).body(axum::body::Body::empty()).unwrap())
805 .await
806 .unwrap();
807 response.headers()[header::CACHE_CONTROL]
808 .to_str()
809 .unwrap()
810 .to_owned()
811 }
812 };
813
814 assert_eq!(cache_control("/?utm_source=x").await, "public, no-cache");
816 assert_eq!(cache_control("/search?q=rust").await, "public, no-cache");
817 assert_eq!(
819 cache_control("/preset").await,
820 "public, max-age=0, s-maxage=60"
821 );
822 assert_eq!(
824 cache_control("/doc/yggdrasil/index.html").await,
825 "public, max-age=300, stale-while-revalidate=3600"
826 );
827 assert_eq!(
828 cache_control("/favicon.svg").await,
829 "public, max-age=300, stale-while-revalidate=3600"
830 );
831 }
832
833 #[tokio::test]
834 async fn panic_in_public_page_returns_uncached_500() {
835 async fn boom() -> &'static str {
836 panic!("内部细节 secret")
837 }
838
839 let app = axum::Router::new()
840 .route("/post/x", axum::routing::get(boom))
841 .layer(axum::middleware::from_fn(|req, next| {
842 super::add_cache_control(false, req, next)
843 }))
844 .layer(super::catch_panic_layer());
845
846 let response = app
847 .oneshot(
848 Request::get("/post/x")
849 .body(axum::body::Body::empty())
850 .unwrap(),
851 )
852 .await
853 .unwrap();
854
855 assert_eq!(
856 response.status(),
857 axum::http::StatusCode::INTERNAL_SERVER_ERROR
858 );
859 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
860 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
861 .await
862 .unwrap();
863 let body = std::str::from_utf8(&body).unwrap();
864 assert_eq!(body, "服务器内部错误");
865 assert!(!body.contains("secret"));
866 }
867}