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