1#![cfg(feature = "server")]
15
16use std::path::PathBuf;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::LazyLock;
19
20static GLOBAL_GENERATION: LazyLock<AtomicU64> = LazyLock::new(AtomicU64::default);
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct SsrGeneration(pub u64);
32
33const SSR_CACHE_ROOT: &str = "static";
38
39fn route_cache_dir(route: &str) -> Option<PathBuf> {
45 let mut path = PathBuf::from(SSR_CACHE_ROOT);
46 for seg in route.trim_start_matches('/').split('/') {
47 if seg.is_empty() || seg == ".." || seg == "." {
48 continue;
49 }
50 path.push(seg);
51 }
52 if path.as_path() == std::path::Path::new(SSR_CACHE_ROOT) {
53 None } else {
55 Some(path)
56 }
57}
58
59pub fn invalidate_ssr_route(route: &str) {
65 let Some(dir) = route_cache_dir(route) else {
66 return;
67 };
68 match std::fs::remove_dir_all(&dir) {
69 Ok(()) => tracing::debug!(route = route, dir = %dir.display(), "SSR 路由缓存已删除"),
70 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
71 Err(e) => tracing::warn!(route = route, error = %e, "删除 SSR 路由缓存失败"),
72 }
73}
74
75pub fn invalidate_post_preview(slug: &str) {
81 invalidate_ssr_route(&format!("/admin/preview/{slug}"));
82}
83
84pub fn invalidate_ssr_all_public() {
89 let root = PathBuf::from(SSR_CACHE_ROOT);
90 let Ok(entries) = std::fs::read_dir(&root) else {
91 return; };
93 for entry in entries.flatten() {
94 let name = entry.file_name();
95 let name = name.to_string_lossy();
96 if name == ".well-known" || name == "admin" {
97 continue;
98 }
99 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
100 if e.kind() != std::io::ErrorKind::NotFound {
101 tracing::warn!(entry = %name, error = %e, "删除 SSR 缓存条目失败");
102 }
103 }
104 }
105 tracing::debug!("已失效全部公开页 SSR 缓存");
106}
107
108pub fn bump_global_generation() -> u64 {
113 let new = GLOBAL_GENERATION
114 .fetch_add(1, Ordering::SeqCst)
115 .wrapping_add(1);
116 tracing::debug!(new_generation = new, "SSR 全局世代号已递增");
117 new
118}
119
120pub fn current_global_generation() -> u64 {
122 GLOBAL_GENERATION.load(Ordering::SeqCst)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use serial_test::serial;
129
130 #[test]
131 #[serial]
132 fn global_generation_is_monotonic() {
133 let before = current_global_generation();
134 let g1 = bump_global_generation();
135 let g2 = bump_global_generation();
136 let current = current_global_generation();
137
138 assert!(g1 > before || g1 == 1);
139 assert!(g2 > g1);
140 assert_eq!(current, g2);
141 }
142
143 #[test]
144 fn route_cache_dir_rejects_traversal() {
145 let p = route_cache_dir("/post/../../../etc/passwd").unwrap();
147 let segs: Vec<_> = p.components().collect();
148 assert!(p.starts_with("static"));
150 assert!(!segs.iter().any(|c| c.as_os_str() == ".."));
151 }
152
153 #[test]
154 fn route_cache_dir_normalizes_leading_slash() {
155 let a = route_cache_dir("/post/foo").unwrap();
156 let b = route_cache_dir("post/foo").unwrap();
157 assert_eq!(a, b);
158 assert!(a.ends_with("post/foo"));
159 }
160
161 #[test]
162 fn route_cache_dir_none_for_root() {
163 assert!(route_cache_dir("/").is_none());
165 assert!(route_cache_dir("").is_none());
166 }
167
168 #[test]
169 fn invalidate_ssr_route_deletes_dir() {
170 let dir = route_cache_dir("/__test_route_xyz").unwrap();
172 std::fs::create_dir_all(dir.join("index")).unwrap();
173 std::fs::write(dir.join("index").join("fake.html"), "stale").unwrap();
174 assert!(dir.exists());
175
176 invalidate_ssr_route("/__test_route_xyz");
177
178 assert!(!dir.exists(), "删除后目录不应存在");
179 }
180
181 #[test]
182 fn invalidate_ssr_route_missing_is_noop() {
183 invalidate_ssr_route("/__never_exists_xyz_123");
185 }
186
187 #[test]
188 fn invalidate_ssr_all_public_is_safe_on_missing_root() {
189 invalidate_ssr_all_public();
194 }
195}