|
1 |
| -use super::{ |
2 |
| - cache::CachePolicy, |
3 |
| - error::{AxumNope, AxumResult}, |
4 |
| -}; |
5 |
| -use crate::utils::report_error; |
6 |
| -use anyhow::Context; |
| 1 | +use super::{cache::CachePolicy, error::AxumNope, metrics::request_recorder, routes::get_static}; |
7 | 2 | use axum::{
|
8 |
| - extract::{Extension, Path}, |
9 |
| - http::{ |
10 |
| - header::{CONTENT_LENGTH, CONTENT_TYPE, LAST_MODIFIED}, |
11 |
| - StatusCode, |
12 |
| - }, |
| 3 | + extract::Extension, |
| 4 | + headers::HeaderValue, |
| 5 | + http::{header::CONTENT_TYPE, Request}, |
| 6 | + middleware, |
| 7 | + middleware::Next, |
13 | 8 | response::{IntoResponse, Response},
|
| 9 | + routing::get_service, |
| 10 | + Router as AxumRouter, |
14 | 11 | };
|
15 |
| -use chrono::prelude::*; |
16 |
| -use httpdate::fmt_http_date; |
17 |
| -use mime::Mime; |
18 |
| -use mime_guess::MimeGuess; |
19 |
| -use std::{ffi::OsStr, path, time::SystemTime}; |
20 |
| -use tokio::fs; |
| 12 | +use std::io; |
| 13 | +use tower_http::services::ServeDir; |
21 | 14 |
|
22 | 15 | const VENDORED_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/vendored.css"));
|
23 | 16 | const STYLE_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/style.css"));
|
24 | 17 | const RUSTDOC_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/rustdoc.css"));
|
25 | 18 | const RUSTDOC_2021_12_05_CSS: &str =
|
26 | 19 | include_str!(concat!(env!("OUT_DIR"), "/rustdoc-2021-12-05.css"));
|
27 |
| -const STATIC_SEARCH_PATHS: &[&str] = &["static", "vendor"]; |
28 |
| - |
29 |
| -pub(crate) async fn static_handler(Path(path): Path<String>) -> AxumResult<impl IntoResponse> { |
30 |
| - let text_css: Mime = "text/css".parse().unwrap(); |
31 |
| - |
32 |
| - Ok(match path.as_str() { |
33 |
| - "vendored.css" => build_response(VENDORED_CSS, text_css), |
34 |
| - "style.css" => build_response(STYLE_CSS, text_css), |
35 |
| - "rustdoc.css" => build_response(RUSTDOC_CSS, text_css), |
36 |
| - "rustdoc-2021-12-05.css" => build_response(RUSTDOC_2021_12_05_CSS, text_css), |
37 |
| - file => match serve_file(file).await { |
38 |
| - Ok(response) => response.into_response(), |
39 |
| - Err(err) => return Err(err), |
40 |
| - }, |
41 |
| - }) |
42 |
| -} |
43 |
| - |
44 |
| -async fn serve_file(file: &str) -> AxumResult<impl IntoResponse> { |
45 |
| - // Find the first path that actually exists |
46 |
| - let path = STATIC_SEARCH_PATHS |
47 |
| - .iter() |
48 |
| - .find_map(|root| { |
49 |
| - let path = path::Path::new(root).join(file); |
50 |
| - if !path.exists() { |
51 |
| - return None; |
52 |
| - } |
53 | 20 |
|
54 |
| - // Prevent accessing static files outside the root. This could happen if the path |
55 |
| - // contains `/` or `..`. The check doesn't outright prevent those strings to be present |
56 |
| - // to allow accessing files in subdirectories. |
57 |
| - let canonical_path = std::fs::canonicalize(path).ok()?; |
58 |
| - let canonical_root = std::fs::canonicalize(root).ok()?; |
59 |
| - if canonical_path.starts_with(canonical_root) { |
60 |
| - Some(canonical_path) |
61 |
| - } else { |
62 |
| - None |
63 |
| - } |
64 |
| - }) |
65 |
| - .ok_or(AxumNope::ResourceNotFound)?; |
66 |
| - |
67 |
| - let contents = fs::read(&path) |
68 |
| - .await |
69 |
| - .with_context(|| format!("failed to read static file {}", path.display())) |
70 |
| - .map_err(|e| { |
71 |
| - report_error(&e); |
72 |
| - AxumNope::InternalServerError |
73 |
| - })?; |
74 |
| - |
75 |
| - // If we can detect the file's mime type, set it |
76 |
| - // MimeGuess misses a lot of the file types we need, so there's a small wrapper |
77 |
| - // around it |
78 |
| - let content_type: Mime = if file == "opensearch.xml" { |
79 |
| - "application/opensearchdescription+xml".parse().unwrap() |
80 |
| - } else { |
81 |
| - path.extension() |
82 |
| - .and_then(OsStr::to_str) |
83 |
| - .and_then(|ext| match ext { |
84 |
| - "eot" => Some("application/vnd.ms-fontobject".parse().unwrap()), |
85 |
| - "woff2" => Some("application/font-woff2".parse().unwrap()), |
86 |
| - "ttf" => Some("application/x-font-ttf".parse().unwrap()), |
87 |
| - _ => MimeGuess::from_path(&path).first(), |
88 |
| - }) |
89 |
| - .unwrap_or(mime::APPLICATION_OCTET_STREAM) |
90 |
| - }; |
91 |
| - |
92 |
| - Ok(build_response(contents, content_type)) |
| 21 | +async fn handle_error(err: io::Error) -> impl IntoResponse { |
| 22 | + AxumNope::InternalError(err.into()).into_response() |
93 | 23 | }
|
94 | 24 |
|
95 |
| -fn build_response<R>(resource: R, content_type: Mime) -> Response |
96 |
| -where |
97 |
| - R: AsRef<[u8]>, |
98 |
| -{ |
| 25 | +fn build_static_response(content: &'static str) -> impl IntoResponse { |
99 | 26 | (
|
100 |
| - StatusCode::OK, |
101 | 27 | Extension(CachePolicy::ForeverInCdnAndBrowser),
|
102 |
| - [ |
103 |
| - (CONTENT_LENGTH, resource.as_ref().len().to_string()), |
104 |
| - (CONTENT_TYPE, content_type.to_string()), |
105 |
| - (LAST_MODIFIED, fmt_http_date(SystemTime::from(Utc::now()))), |
106 |
| - ], |
107 |
| - resource.as_ref().to_vec(), |
| 28 | + [(CONTENT_TYPE, mime::TEXT_CSS.as_ref())], |
| 29 | + content, |
108 | 30 | )
|
109 |
| - .into_response() |
| 31 | +} |
| 32 | + |
| 33 | +async fn set_needed_static_headers<B>(req: Request<B>, next: Next<B>) -> Response { |
| 34 | + let is_opensearch_xml = req.uri().path().ends_with("/opensearch.xml"); |
| 35 | + |
| 36 | + let mut response = next.run(req).await; |
| 37 | + |
| 38 | + if response.status().is_success() { |
| 39 | + response |
| 40 | + .extensions_mut() |
| 41 | + .insert(CachePolicy::ForeverInCdnAndBrowser); |
| 42 | + } |
| 43 | + |
| 44 | + if is_opensearch_xml { |
| 45 | + // overwrite the content type for opensearch.xml, |
| 46 | + // otherwise mime-guess would return `text/xml`. |
| 47 | + response.headers_mut().insert( |
| 48 | + CONTENT_TYPE, |
| 49 | + HeaderValue::from_static("application/opensearchdescription+xml"), |
| 50 | + ); |
| 51 | + } |
| 52 | + |
| 53 | + response |
| 54 | +} |
| 55 | + |
| 56 | +pub(crate) fn build_static_router() -> AxumRouter { |
| 57 | + AxumRouter::new() |
| 58 | + .route( |
| 59 | + "/vendored.css", |
| 60 | + get_static(|| async { build_static_response(VENDORED_CSS) }), |
| 61 | + ) |
| 62 | + .route( |
| 63 | + "/style.css", |
| 64 | + get_static(|| async { build_static_response(STYLE_CSS) }), |
| 65 | + ) |
| 66 | + .route( |
| 67 | + "/rustdoc.css", |
| 68 | + get_static(|| async { build_static_response(RUSTDOC_CSS) }), |
| 69 | + ) |
| 70 | + .route( |
| 71 | + "/rustdoc-2021-12-05.css", |
| 72 | + get_static(|| async { build_static_response(RUSTDOC_2021_12_05_CSS) }), |
| 73 | + ) |
| 74 | + .nest_service( |
| 75 | + "/", |
| 76 | + get_service(ServeDir::new("static").fallback(ServeDir::new("vendor"))) |
| 77 | + .handle_error(handle_error) |
| 78 | + .layer(middleware::from_fn(set_needed_static_headers)) |
| 79 | + .layer(middleware::from_fn(|request, next| async { |
| 80 | + request_recorder(request, next, Some("static resource")).await |
| 81 | + })), |
| 82 | + ) |
110 | 83 | }
|
111 | 84 |
|
112 | 85 | #[cfg(test)]
|
113 | 86 | mod tests {
|
114 |
| - use super::{serve_file, STATIC_SEARCH_PATHS, STYLE_CSS, VENDORED_CSS}; |
| 87 | + use super::{STYLE_CSS, VENDORED_CSS}; |
115 | 88 | use crate::{
|
116 | 89 | test::{assert_cache_control, wrapper},
|
117 |
| - web::{cache::CachePolicy, error::AxumNope}, |
| 90 | + web::cache::CachePolicy, |
118 | 91 | };
|
119 | 92 | use reqwest::StatusCode;
|
120 | 93 | use std::fs;
|
121 | 94 | use test_case::test_case;
|
122 | 95 |
|
| 96 | + const STATIC_SEARCH_PATHS: &[&str] = &["static", "vendor"]; |
| 97 | + |
123 | 98 | #[test]
|
124 | 99 | fn style_css() {
|
125 | 100 | wrapper(|env| {
|
@@ -243,28 +218,4 @@ mod tests {
|
243 | 218 | Ok(())
|
244 | 219 | });
|
245 | 220 | }
|
246 |
| - |
247 |
| - #[tokio::test] |
248 |
| - async fn directory_traversal() { |
249 |
| - const PATHS: &[&str] = &[ |
250 |
| - "../LICENSE", |
251 |
| - "%2e%2e%2fLICENSE", |
252 |
| - "%2e%2e/LICENSE", |
253 |
| - "..%2fLICENSE", |
254 |
| - "%2e%2e%5cLICENSE", |
255 |
| - ]; |
256 |
| - |
257 |
| - for path in PATHS { |
258 |
| - // This doesn't test an actual web request as the web framework used at the time of |
259 |
| - // writing this test (iron 0.5) already resolves `..` before calling any handler. |
260 |
| - // |
261 |
| - // Still, the test ensures the underlying function called by the request handler to |
262 |
| - // serve the file also includes protection for path traversal, in the event we switch |
263 |
| - // to a framework that doesn't include builtin protection in the future. |
264 |
| - assert!( |
265 |
| - matches!(serve_file(path).await, Err(AxumNope::ResourceNotFound)), |
266 |
| - "{path} did not return a 404" |
267 |
| - ); |
268 |
| - } |
269 |
| - } |
270 | 221 | }
|
0 commit comments