1#![cfg(feature = "server")]
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub(crate) struct CompressionAlgorithms {
14 gzip: bool,
15 brotli: bool,
16 deflate: bool,
17 zstd: bool,
18}
19
20impl CompressionAlgorithms {
21 fn all_enabled() -> Self {
22 Self {
23 gzip: true,
24 brotli: true,
25 deflate: true,
26 zstd: true,
27 }
28 }
29
30 fn is_empty(&self) -> bool {
31 !self.gzip && !self.brotli && !self.deflate && !self.zstd
32 }
33}
34
35pub(crate) fn parse_compression_algorithms(env: &str) -> Option<CompressionAlgorithms> {
38 let env = env.trim();
39 if env.is_empty() || env.eq_ignore_ascii_case("none") || env.eq_ignore_ascii_case("off") {
40 return None;
41 }
42
43 let mut all = false;
44 let mut gzip = false;
45 let mut brotli = false;
46 let mut deflate = false;
47 let mut zstd = false;
48
49 for part in env.split(',') {
50 match part.trim().to_lowercase().as_str() {
51 "all" => all = true,
52 "gzip" => gzip = true,
53 "brotli" | "br" => brotli = true,
54 "deflate" => deflate = true,
55 "zstd" => zstd = true,
56 other => tracing::warn!(
57 "Unknown compression algorithm in COMPRESSION_ALGORITHMS: '{}'",
58 other
59 ),
60 }
61 }
62
63 if all {
64 return Some(CompressionAlgorithms::all_enabled());
65 }
66
67 let algorithms = CompressionAlgorithms {
68 gzip,
69 brotli,
70 deflate,
71 zstd,
72 };
73 if algorithms.is_empty() {
74 return None;
75 }
76
77 Some(algorithms)
78}
79
80pub(crate) fn compression_layer_from_env() -> Option<tower_http::compression::CompressionLayer> {
93 use tower_http::compression::CompressionLayer;
94
95 let env = std::env::var("COMPRESSION_ALGORITHMS").unwrap_or_else(|_| "off".to_string());
96 let algorithms = parse_compression_algorithms(&env)?;
97
98 Some(
99 CompressionLayer::new()
100 .gzip(algorithms.gzip)
101 .br(algorithms.brotli)
102 .deflate(algorithms.deflate)
103 .zstd(algorithms.zstd),
104 )
105}
106
107pub(crate) fn cache_control_for_path(
110 path: &str,
111 method: &axum::http::Method,
112) -> Option<axum::http::HeaderValue> {
113 use axum::http::{HeaderValue, Method};
114
115 if *method != Method::GET && *method != Method::HEAD {
117 return None;
118 }
119
120 if path.starts_with("/api") {
122 return None;
123 }
124
125 if path.starts_with("/admin") || path == "/login" || path == "/register" {
127 return None;
128 }
129
130 if path.starts_with("/_dioxus/")
133 || path.starts_with("/wasm/")
134 || path.ends_with(".wasm")
135 || path.ends_with(".js")
136 || path.ends_with(".css")
137 {
138 return Some(HeaderValue::from_static("public, no-cache"));
139 }
140
141 Some(HeaderValue::from_static(
143 "public, max-age=300, stale-while-revalidate=3600",
144 ))
145}
146
147pub(crate) async fn add_cache_control(
149 req: axum::extract::Request,
150 next: axum::middleware::Next,
151) -> axum::response::Response {
152 use axum::http::header;
153
154 let path = req.uri().path().to_string();
155 let cache_value = cache_control_for_path(&path, req.method());
156
157 let mut response = next.run(req).await;
158
159 if let Some(value) = cache_value {
160 response
162 .headers_mut()
163 .entry(header::CACHE_CONTROL)
164 .or_insert(value);
165 }
166
167 response
168}
169
170pub(crate) async fn admin_guard(
184 req: axum::extract::Request,
185 next: axum::middleware::Next,
186) -> axum::response::Response {
187 use crate::models::user::UserRole;
188 use axum::body::Body;
189 use axum::http::{header, StatusCode};
190 use axum::response::Response;
191
192 let path = req.uri().path().to_string();
193 if !path.starts_with("/admin") {
194 return next.run(req).await;
195 }
196
197 let cookie = req
199 .headers()
200 .get("cookie")
201 .and_then(|h| h.to_str().ok())
202 .unwrap_or("");
203 let token = crate::auth::session::parse_session_token(cookie);
204
205 let is_admin = match token {
206 Some(t) => match crate::api::auth::get_user_by_token(t).await {
207 Ok(Some(user)) => user.role == UserRole::Admin,
208 _ => true,
210 },
211 None => false,
213 };
214
215 if is_admin {
216 next.run(req).await
217 } else {
218 Response::builder()
219 .status(StatusCode::FOUND)
220 .header(header::LOCATION, "/login")
221 .body(Body::empty())
222 .expect("静态 302 重定向响应(合法 status + 固定 header + 空 body)必然构造成功")
223 }
224}
225
226pub(crate) async fn ssr_generation_middleware(
230 req: axum::extract::Request,
231 next: axum::middleware::Next,
232) -> axum::response::Response {
233 let generation = crate::ssr_cache::current_global_generation();
234 let is_get = req.method() == axum::http::Method::GET;
235 let (mut parts, body) = req.into_parts();
236 parts
237 .extensions
238 .insert(crate::ssr_cache::SsrGeneration(generation));
239 let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
240 if is_get {
241 response.headers_mut().insert(
242 axum::http::header::HeaderName::from_static("x-ssr-generation"),
243 axum::http::HeaderValue::from_str(&generation.to_string())
244 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")),
245 );
246 }
247 response
248}
249
250pub(crate) async fn version_headers_middleware(
257 req: axum::extract::Request,
258 next: axum::middleware::Next,
259) -> axum::response::Response {
260 let mut response = next.run(req).await;
261 let h = response.headers_mut();
262 h.insert(
263 axum::http::header::SERVER,
264 axum::http::HeaderValue::from_str(&format!(
265 "yggdrasil/{}",
266 crate::build_info::BUILD_INFO.version
267 ))
268 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")),
269 );
270 h.insert(
271 axum::http::header::HeaderName::from_static("x-yggdrasil-version"),
272 axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version),
273 );
274 h.insert(
275 axum::http::header::HeaderName::from_static("x-yggdrasil-git"),
276 axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe)
277 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
278 );
279 h.insert(
280 axum::http::header::HeaderName::from_static("x-yggdrasil-hash"),
281 axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_hash)
282 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
283 );
284 response
285}
286
287#[cfg(test)]
288mod tests {
289 use super::{cache_control_for_path, parse_compression_algorithms, CompressionAlgorithms};
290 use axum::http::Method;
291
292 fn cache_value(path: &str, method: Method) -> Option<String> {
293 cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
294 }
295
296 #[test]
297 fn public_page_is_cached() {
298 assert_eq!(
299 cache_value("/", Method::GET),
300 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
301 );
302 assert_eq!(
303 cache_value("/post/hello-world", Method::GET),
304 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
305 );
306 assert_eq!(
307 cache_value("/tags/rust", Method::GET),
308 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
309 );
310 }
311
312 #[test]
313 fn unversioned_assets_require_revalidation() {
314 for path in [
315 "/style.css",
316 "/highlight.css",
317 "/tiptap/editor.css",
318 "/xterm/terminal.css",
319 "/tiptap/editor.js",
320 "/codemirror/editor.js",
321 "/yggdrasil-core/yggdrasil-core.js",
322 "/mermaid/mermaid.js",
323 "/wasm/app.wasm",
324 "/wasm/app.js",
325 "/_dioxus/assets/main.js",
326 ] {
327 for method in [Method::GET, Method::HEAD] {
328 assert_eq!(
329 cache_value(path, method).as_deref(),
330 Some("public, no-cache"),
331 "{path}"
332 );
333 }
334 }
335 }
336
337 #[test]
338 fn api_and_admin_and_auth_are_not_cached() {
339 assert_eq!(cache_value("/api/posts", Method::GET), None);
340 assert_eq!(cache_value("/admin", Method::GET), None);
341 assert_eq!(cache_value("/admin/posts", Method::GET), None);
342 assert_eq!(cache_value("/login", Method::GET), None);
343 assert_eq!(cache_value("/register", Method::GET), None);
344 }
345
346 #[test]
347 fn non_get_requests_are_not_cached() {
348 assert_eq!(cache_value("/", Method::POST), None);
349 assert_eq!(cache_value("/post/hello-world", Method::POST), None);
350 assert_eq!(cache_value("/style.css", Method::POST), None);
351 }
352
353 #[test]
354 fn head_requests_are_cached_like_get() {
355 assert_eq!(
356 cache_value("/", Method::HEAD),
357 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
358 );
359 }
360
361 #[test]
362 fn compression_all_enables_everything() {
363 assert_eq!(
364 parse_compression_algorithms("all"),
365 Some(CompressionAlgorithms::all_enabled())
366 );
367 }
368
369 #[test]
370 fn compression_default_env_is_off() {
371 assert_eq!(parse_compression_algorithms("off"), None);
373 }
374
375 #[test]
376 fn compression_empty_none_off_disable() {
377 assert_eq!(parse_compression_algorithms(""), None);
378 assert_eq!(parse_compression_algorithms("none"), None);
379 assert_eq!(parse_compression_algorithms("NONE"), None);
380 assert_eq!(parse_compression_algorithms("off"), None);
381 assert_eq!(parse_compression_algorithms("OFF"), None);
382 }
383
384 #[test]
385 fn compression_single_algorithm() {
386 assert_eq!(
387 parse_compression_algorithms("gzip"),
388 Some(CompressionAlgorithms {
389 gzip: true,
390 brotli: false,
391 deflate: false,
392 zstd: false,
393 })
394 );
395 assert_eq!(
396 parse_compression_algorithms("br"),
397 Some(CompressionAlgorithms {
398 gzip: false,
399 brotli: true,
400 deflate: false,
401 zstd: false,
402 })
403 );
404 }
405
406 #[test]
407 fn compression_multiple_algorithms() {
408 assert_eq!(
409 parse_compression_algorithms("gzip, zstd"),
410 Some(CompressionAlgorithms {
411 gzip: true,
412 brotli: false,
413 deflate: false,
414 zstd: true,
415 })
416 );
417 }
418
419 #[test]
420 fn compression_case_insensitive_and_whitespace_tolerant() {
421 assert_eq!(
422 parse_compression_algorithms("GZIP, Brotli, Deflate, Zstd"),
423 Some(CompressionAlgorithms::all_enabled())
424 );
425 assert_eq!(
426 parse_compression_algorithms(" gzip , br , deflate , zstd "),
427 Some(CompressionAlgorithms::all_enabled())
428 );
429 }
430
431 #[test]
432 fn compression_unknown_algorithms_are_ignored() {
433 assert_eq!(
434 parse_compression_algorithms("gzip, unknown, lz4"),
435 Some(CompressionAlgorithms {
436 gzip: true,
437 brotli: false,
438 deflate: false,
439 zstd: false,
440 })
441 );
442 }
443}