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/")
132 || path.starts_with("/wasm/")
133 || path.ends_with(".wasm")
134 || path.ends_with(".js")
135 || path == "/style.css"
136 || path == "/highlight.css"
137 {
138 return Some(HeaderValue::from_static(
139 "public, max-age=31536000, immutable",
140 ));
141 }
142
143 Some(HeaderValue::from_static(
145 "public, max-age=300, stale-while-revalidate=3600",
146 ))
147}
148
149pub(crate) async fn add_cache_control(
151 req: axum::extract::Request,
152 next: axum::middleware::Next,
153) -> axum::response::Response {
154 use axum::http::header;
155
156 let path = req.uri().path().to_string();
157 let method = req.method().clone();
158 let cache_value = cache_control_for_path(&path, &method);
159
160 let mut response = next.run(req).await;
161
162 if let Some(value) = cache_value {
163 response
165 .headers_mut()
166 .entry(header::CACHE_CONTROL)
167 .or_insert(value);
168 }
169
170 response
171}
172
173pub(crate) async fn admin_guard(
187 req: axum::extract::Request,
188 next: axum::middleware::Next,
189) -> axum::response::Response {
190 use crate::models::user::UserRole;
191 use axum::body::Body;
192 use axum::http::{header, StatusCode};
193 use axum::response::Response;
194
195 let path = req.uri().path().to_string();
196 if !path.starts_with("/admin") {
197 return next.run(req).await;
198 }
199
200 let cookie = req
202 .headers()
203 .get("cookie")
204 .and_then(|h| h.to_str().ok())
205 .unwrap_or("");
206 let token = crate::auth::session::parse_session_token(cookie);
207
208 let is_admin = match token {
209 Some(t) => match crate::api::auth::get_user_by_token(t).await {
210 Ok(Some(user)) => user.role == UserRole::Admin,
211 _ => true,
213 },
214 None => false,
216 };
217
218 if is_admin {
219 next.run(req).await
220 } else {
221 Response::builder()
222 .status(StatusCode::FOUND)
223 .header(header::LOCATION, "/login")
224 .body(Body::empty())
225 .expect("静态 302 重定向响应(合法 status + 固定 header + 空 body)必然构造成功")
226 }
227}
228
229pub(crate) async fn ssr_generation_middleware(
233 req: axum::extract::Request,
234 next: axum::middleware::Next,
235) -> axum::response::Response {
236 let generation = crate::ssr_cache::current_global_generation();
237 let is_get = req.method() == axum::http::Method::GET;
238 let (mut parts, body) = req.into_parts();
239 parts
240 .extensions
241 .insert(crate::ssr_cache::SsrGeneration(generation));
242 let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
243 if is_get {
244 response.headers_mut().insert(
245 axum::http::header::HeaderName::from_static("x-ssr-generation"),
246 axum::http::HeaderValue::from_str(&generation.to_string())
247 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")),
248 );
249 }
250 response
251}
252
253pub(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 response
280}
281
282#[cfg(test)]
283mod tests {
284 use super::{cache_control_for_path, parse_compression_algorithms, CompressionAlgorithms};
285 use axum::http::Method;
286
287 fn cache_value(path: &str, method: Method) -> Option<String> {
288 cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
289 }
290
291 #[test]
292 fn public_page_is_cached() {
293 assert_eq!(
294 cache_value("/", Method::GET),
295 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
296 );
297 assert_eq!(
298 cache_value("/post/hello-world", Method::GET),
299 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
300 );
301 assert_eq!(
302 cache_value("/tags/rust", Method::GET),
303 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
304 );
305 }
306
307 #[test]
308 fn static_assets_are_cached_long_term() {
309 assert_eq!(
310 cache_value("/style.css", Method::GET),
311 Some("public, max-age=31536000, immutable".to_string())
312 );
313 assert_eq!(
314 cache_value("/highlight.css", Method::GET),
315 Some("public, max-age=31536000, immutable".to_string())
316 );
317 assert_eq!(
318 cache_value("/wasm/app.wasm", Method::GET),
319 Some("public, max-age=31536000, immutable".to_string())
320 );
321 assert_eq!(
322 cache_value("/_dioxus/assets/main.js", Method::GET),
323 Some("public, max-age=31536000, immutable".to_string())
324 );
325 }
326
327 #[test]
328 fn api_and_admin_and_auth_are_not_cached() {
329 assert_eq!(cache_value("/api/posts", Method::GET), None);
330 assert_eq!(cache_value("/admin", Method::GET), None);
331 assert_eq!(cache_value("/admin/posts", Method::GET), None);
332 assert_eq!(cache_value("/login", Method::GET), None);
333 assert_eq!(cache_value("/register", Method::GET), None);
334 }
335
336 #[test]
337 fn non_get_requests_are_not_cached() {
338 assert_eq!(cache_value("/", Method::POST), None);
339 assert_eq!(cache_value("/post/hello-world", Method::POST), None);
340 assert_eq!(cache_value("/style.css", Method::POST), None);
341 }
342
343 #[test]
344 fn head_requests_are_cached_like_get() {
345 assert_eq!(
346 cache_value("/", Method::HEAD),
347 Some("public, max-age=300, stale-while-revalidate=3600".to_string())
348 );
349 }
350
351 #[test]
352 fn compression_all_enables_everything() {
353 assert_eq!(
354 parse_compression_algorithms("all"),
355 Some(CompressionAlgorithms::all_enabled())
356 );
357 }
358
359 #[test]
360 fn compression_default_env_is_off() {
361 assert_eq!(parse_compression_algorithms("off"), None);
363 }
364
365 #[test]
366 fn compression_empty_none_off_disable() {
367 assert_eq!(parse_compression_algorithms(""), None);
368 assert_eq!(parse_compression_algorithms("none"), None);
369 assert_eq!(parse_compression_algorithms("NONE"), None);
370 assert_eq!(parse_compression_algorithms("off"), None);
371 assert_eq!(parse_compression_algorithms("OFF"), None);
372 }
373
374 #[test]
375 fn compression_single_algorithm() {
376 assert_eq!(
377 parse_compression_algorithms("gzip"),
378 Some(CompressionAlgorithms {
379 gzip: true,
380 brotli: false,
381 deflate: false,
382 zstd: false,
383 })
384 );
385 assert_eq!(
386 parse_compression_algorithms("br"),
387 Some(CompressionAlgorithms {
388 gzip: false,
389 brotli: true,
390 deflate: false,
391 zstd: false,
392 })
393 );
394 }
395
396 #[test]
397 fn compression_multiple_algorithms() {
398 assert_eq!(
399 parse_compression_algorithms("gzip, zstd"),
400 Some(CompressionAlgorithms {
401 gzip: true,
402 brotli: false,
403 deflate: false,
404 zstd: true,
405 })
406 );
407 }
408
409 #[test]
410 fn compression_case_insensitive_and_whitespace_tolerant() {
411 assert_eq!(
412 parse_compression_algorithms("GZIP, Brotli, Deflate, Zstd"),
413 Some(CompressionAlgorithms::all_enabled())
414 );
415 assert_eq!(
416 parse_compression_algorithms(" gzip , br , deflate , zstd "),
417 Some(CompressionAlgorithms::all_enabled())
418 );
419 }
420
421 #[test]
422 fn compression_unknown_algorithms_are_ignored() {
423 assert_eq!(
424 parse_compression_algorithms("gzip, unknown, lz4"),
425 Some(CompressionAlgorithms {
426 gzip: true,
427 brotli: false,
428 deflate: false,
429 zstd: false,
430 })
431 );
432 }
433}