Skip to main content

yggdrasil/
middleware.rs

1//! Axum 中间件与启动期纯函数。
2//!
3//! 从 `main.rs` 抽出的、可独立测试的服务端 HTTP 中间件(cache-control、admin
4//! 守卫、上一发布资源保留窗口)。响应压缩在 `crate::compression`。整体 server-only。
5//!
6//! 这些函数此前散落在 `main.rs`,既无法单独测试也使入口职责过载。迁移后
7//! `startup.rs` 的路由组装以全路径 `crate::middleware::xxx` 引用,语义不变。
8
9#![cfg(feature = "server")]
10
11/// 前端入口文件前缀(`[package.metadata.leptos] output-name = "yggdrasil"`)。
12const PKG_ENTRY_PREFIX: &str = "/pkg/yggdrasil.";
13
14/// `/pkg/` 下的文件名是否形如 `<name>.<22 位 [A-Za-z0-9_-]>.<扩展名>`(cargo-leptos 的内容哈希命名)。
15fn 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
30/// rustdoc 产物里文件名带内容哈希、内容永不变化的文件:
31/// - `/doc/static.files/<名字>-<8 位十六进制>.<扩展名>`(样式、脚本、字体,如
32///   `rustdoc-0c87338a.css`、`SourceSerif4-Regular-6b053e98.ttf.woff2`);
33/// - `/doc/search.index/<列>/<十六进制>.js`(搜索索引分片,按内容哈希命名)。
34///
35/// `/doc/search.index/root.js`、`doc-shell.js`、`theme.css` 等固定文件名不在此列。
36fn 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";
57/// 其余公开 GET 的默认值:5 分钟新鲜期,过期后 1 小时内可先给旧内容再后台校验。
58const PUBLIC_DEFAULT: &str = "public, max-age=300, stale-while-revalidate=3600";
59
60/// 根据请求路径、查询串和方法决定公开页面的 Cache-Control 头。
61/// 返回 None 表示不添加缓存头(保留现有行为或避免覆盖)。
62///
63/// `entry_content_addressed`:hash.txt 是否带 `entry: content-addressed`(见
64/// `scripts/build-release.ts` 的 contentAddressEntries),决定入口文件能否 immutable。
65pub(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    // 只对 GET/HEAD 请求添加缓存头
74    if *method != Method::GET && *method != Method::HEAD {
75        return None;
76    }
77
78    // 可撤回的笔记与受保护图片不缓存,包含未公开时的 404 响应。
79    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    // 管理后台和认证页面:不缓存
93    if crate::routes::is_admin_path(path) || path == "/login" || path == "/register" {
94        return None;
95    }
96
97    // /pkg/ 下 cargo-leptos 产物:文件名带内容哈希(`<name>.<22 位哈希>.<扩展名>`)的永不变化,
98    // 可长期缓存;其余(固定文件名)随部署变化。
99    //
100    // 入口 `yggdrasil.<哈希>.{js,wasm}` 只有发布装配按最终内容重命名后才 immutable:cargo-leptos
101    // 先按内容算哈希改名,之后才把分包加载器 `__wasm_split.<哈希>.js` 的文件名写进入口。未经装配
102    // 的产物(dev、回归构建)里,只改懒加载代码会换掉加载器文件名而入口同名;入口若 immutable,
103    // 回访浏览器会一直用旧入口去请求已删除的旧加载器,整页无法水合,所以仍每次校验。
104    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    // rustdoc 的哈希命名文件(含字体)同样永不变化;此前样式/脚本每次回源校验、字体只缓存 5 分钟,
115    // 而 `/doc/` 占了 CDN 出流量的大头。
116    if is_hashed_doc_file(path) {
117        return Some(HeaderValue::from_static(IMMUTABLE));
118    }
119
120    // 固定路径的脚本、样式和 WASM 会随部署变化。`?v=` 等于当前内容版本号的地址
121    // (`static_assets::url` 生成)内容不会再变,可长期缓存;其余每次使用前须校验。
122    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    // 其余公开资源:5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证。
133    // 服务端渲染的 HTML 在 `add_cache_control` 里按响应类型另行改成 `no-cache`。
134    Some(HeaderValue::from_static(PUBLIC_DEFAULT))
135}
136
137/// 服务端渲染的 HTML 只在源站能主动失效时才允许共享缓存:白名单匿名页由
138/// `ssr_cache::cache_public_html` 自行设置(边缘缓存 + 写入后清 EdgeOne),其余页面
139/// (查询串变体、搜索、404 等)一律 `no-cache`,否则 CDN 会把改过或删掉的内容再给 5 分钟。
140/// `/doc/` 下是随发版更新的静态 rustdoc 页面,不在此列。
141fn 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
150/// Axum 中间件:为公开页面和静态资源附加 Cache-Control 头。
151pub(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    // 长期缓存只给成功响应:哈希文件名的 404(旧页面请求已下线的分包、新页面先于文件到达)
169    // 若带 immutable,浏览器与 CDN 会把「不存在」记一年。
170    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        // 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
184        response
185            .headers_mut()
186            .entry(header::CACHE_CONTROL)
187            .or_insert(value);
188    }
189
190    response
191}
192
193/// 请求处理 panic 时返回给客户端的固定文案;panic 内容只写日志。
194const PANIC_RESPONSE_BODY: &str = "服务器内部错误";
195
196/// 把 handler 及其内层中间件在响应头发出前的 panic 转成 500。
197pub(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    // 显式 no-store:panic 越过 add_cache_control 展开,响应不会带任何 Cache-Control,
213    // CDN 可能按默认规则缓存这个 500。
214    (
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
228/// Axum 中间件:`/admin*` 的 SSR 层认证守卫。
229///
230/// 未登录访问后台时,服务端**直接 302 跳转 `/login`**,根本不进入前端应用
231/// SSR 渲染器。此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM
232/// 下载/编译 → hydrate → 异步 `get_current_user()` → 客户端 `navigator.push`),
233/// 整条链串行,未登录用户首屏要"空白好久"才跳登录。
234///
235/// - 只匹配 `/admin*`,其它路径(`/login`、公开页、`/api/*`)直接放行。
236/// - 复用 `get_user_by_token`:命中内存缓存 + 校验 `session_generation`
237///   (封禁/降级后旧 session 立即失效),与客户端鉴权同一套语义。
238/// - 无 token、token 无效/过期/世代号不匹配、非管理员:302 跳转 `/login`。
239/// - 仅 DB 错误时 **fail-open**(放行进入 SSR):避免数据库抖动把已登录的
240///   管理员也踢到登录页;客户端 `AdminLayout` 仍有兜底校验。
241/// - `/admin` 与 `/login` 本就不进 `cache_control_for_path` 缓存,302 不会被缓存。
242pub(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    // 从 Cookie 头读 session token(与 export.rs / upload.rs 同款手法)。
256    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
283/// [`admin_guard`] 的放行判定。
284///
285/// `session` 为 `None` 表示请求没有 session cookie;`Some(Ok(None))` 表示 token
286/// 无效、过期或世代号不匹配。只有查询出错(`Some(Err(_))`)才 fail-open。
287fn 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        // DB 抖动:fail-open,交给客户端兜底。
294        Some(Err(_)) => true,
295        // 无 token / token 无效:明确未登录,拦截。
296        None | Some(Ok(None)) => false,
297    }
298}
299
300/// Axum 中间件:把当前 SSR 全局世代号注入请求扩展,并对 GET 请求的响应附加
301/// `X-SSR-Generation` 头。世代号同时保护应用 HTML 缓存,阻止失效前的在途响应回填。
302pub(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
323/// Axum 中间件:把分享元信息所需的站点信息(地址、简介、站点图地址)放进 GET 请求
324/// 扩展,页面服务端渲染时由 `components::share_meta` 同步读取(类型见 [`crate::models::share::ShareContext`])。位于 HTML 缓存内层,
325/// 缓存命中时不执行。
326pub(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
337/// Axum 中间件:为所有响应附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git / X-Yggdrasil-Hash 头。
338/// 数据源与启动日志 `log_build_info()` 同源(`crate::build_info::BUILD_INFO`)。
339/// 受 `EXPOSE_VERSION_HEADERS` 控制——该开关在 `startup.rs` 决定是否挂载本层。
340///
341/// 暴露 hash 头的原因:tag 精确 checkout 时 `git describe` 只返回纯 tag 名(如 `v0.10.1`),
342/// 不含 commit hash;单独暴露 `git_hash`(完整 40 位)以精确定位线上二进制对应的提交。
343pub(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
374/// 解析旧发布目录中可能命中的带 hash 资源。只放行 /pkg/(cargo-leptos 内容
375/// 寻址产物目录);URI path 保持百分号编码原样映射,任何非常规组件(`..`、
376/// 根/父目录引用)都不是 Normal 分量,直接拒绝;文件不存在返回 None。
377fn 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
397/// Axum 中间件(由 startup.rs 按需挂载):旧发布资源保留窗口(plan §6.7)。
398///
399/// 滚动更新后,已打开标签页与反代/浏览器缓存的旧 HTML 仍引用上一发布的带
400/// hash 资源(含按路由懒加载的 WASM 分包);新发布未收录这些文件时(404),
401/// 回退到 `YGG_PREVIOUS_PUBLIC_DIR` 同路径文件。语义:
402/// - 只拦截 /pkg/ 的 GET 404,未命中保留原 404,不遮蔽当前发布
403///   的真实缺失;
404/// - 复用 ServeFile 的内容类型推断与 `.br` 旁车协商(release public 自带旁车,
405///   与静态资源服务的预压缩行为一致);
406/// - 命中即 immutable(内容寻址)并带 `x-previous-release` 标记,外层
407///   add_cache_control 的 or_insert 不会覆盖。
408pub(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        // 上一发布同样缺失或读取失败:保留当前发布的 404。
463        _ => 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        // 无 cookie、token 无效/过期/世代号不匹配、非管理员:拦截。
479        assert!(!admin_guard_allows::<()>(None));
480        assert!(!admin_guard_allows::<()>(Some(Ok(None))));
481        assert!(!admin_guard_allows::<()>(Some(Ok(Some(UserRole::Blocked)))));
482        // 有效管理员会话:放行。
483        assert!(admin_guard_allows::<()>(Some(Ok(Some(UserRole::Admin)))));
484        // 仅 DB 错误 fail-open。
485        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        // 缺失文件与目录外前缀一律 None;非 Normal 分量(含 %2e%2e 编码形态的
508        // 字面量不匹配文件名,../ 形态则被分量检查拒绝)。
509        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        // 滚动发布期间旧页面带着旧版本号来请求,拿到的是新内容,不能以旧地址长期缓存。
604        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        // 未经发布装配的入口文件名不随内嵌的加载器文件名变化,必须校验;哈希长度不对或缺少
633        // 哈希段的 /pkg/ 文件同样需要校验。
634        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        // 内容寻址只改变入口的判定,未带哈希的文件仍需校验。
668        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        // 后台样式是公开静态文件,与后台页面同前缀但走版本化缓存。
681        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        // 固定文件名随部署变化,仍每次校验。
719        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        // 查询串变体与非白名单页面:CDN 不缓存。
815        assert_eq!(cache_control("/?utm_source=x").await, "public, no-cache");
816        assert_eq!(cache_control("/search?q=rust").await, "public, no-cache");
817        // 白名单页由内层 HTML 缓存设置,不被覆盖。
818        assert_eq!(
819            cache_control("/preset").await,
820            "public, max-age=0, s-maxage=60"
821        );
822        // 静态 rustdoc 页面与非 HTML 资源保持原默认值。
823        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}