Skip to content

Commit 1c14e2b

Browse files
committed
migrate static asset handler to tower_http::ServeDir
1 parent 85f67fd commit 1c14e2b

File tree

4 files changed

+83
-129
lines changed

4 files changed

+83
-129
lines changed

Cargo.lock

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,8 @@ axum-extra = "0.4.2"
9090
hyper = { version = "0.14.15", default-features = false }
9191
tower = "0.4.11"
9292
tower-service = "0.3.2"
93-
tower-http = { version = "0.3.4", features = ["trace"] }
93+
tower-http = { version = "0.3.4", features = ["fs", "trace"] }
9494
mime = "0.3.16"
95-
httpdate = "1.0.2"
9695
percent-encoding = "2.2.0"
9796

9897
# NOTE: if you change this, also double-check that the comment in `queue_builder::remove_tempdirs` is still accurate.

src/web/routes.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use super::{cache::CachePolicy, error::AxumNope, metrics::request_recorder};
1+
use super::{
2+
cache::CachePolicy, error::AxumNope, metrics::request_recorder, statics::build_static_router,
3+
};
24
use axum::{
35
handler::Handler as AxumHandler,
46
http::Request as AxumHttpRequest,
@@ -15,7 +17,7 @@ use tracing::{debug, instrument};
1517
const INTERNAL_PREFIXES: &[&str] = &["-", "about", "crate", "releases", "sitemap.xml"];
1618

1719
#[instrument(skip_all)]
18-
fn get_static<H, T, S, B>(handler: H) -> MethodRouter<S, B, Infallible>
20+
pub(crate) fn get_static<H, T, S, B>(handler: H) -> MethodRouter<S, B, Infallible>
1921
where
2022
H: AxumHandler<T, S, B>,
2123
B: Send + 'static + hyper::body::HttpBody,
@@ -102,10 +104,7 @@ pub(super) fn build_axum_routes() -> AxumRouter {
102104
"/favicon.ico",
103105
get_static(|| async { Redirect::permanent("/-/static/favicon.ico") }),
104106
)
105-
.route(
106-
"/-/static/*path",
107-
get_static(super::statics::static_handler),
108-
)
107+
.nest("/-/static/", build_static_router())
109108
.route(
110109
"/opensearch.xml",
111110
get_static(|| async { Redirect::permanent("/-/static/opensearch.xml") }),

src/web/statics.rs

Lines changed: 71 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,125 +1,100 @@
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};
72
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,
138
response::{IntoResponse, Response},
9+
routing::get_service,
10+
Router as AxumRouter,
1411
};
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;
2114

2215
const VENDORED_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/vendored.css"));
2316
const STYLE_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/style.css"));
2417
const RUSTDOC_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/rustdoc.css"));
2518
const RUSTDOC_2021_12_05_CSS: &str =
2619
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-
}
5320

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()
9323
}
9424

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 {
9926
(
100-
StatusCode::OK,
10127
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,
10830
)
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+
)
11083
}
11184

11285
#[cfg(test)]
11386
mod tests {
114-
use super::{serve_file, STATIC_SEARCH_PATHS, STYLE_CSS, VENDORED_CSS};
87+
use super::{STYLE_CSS, VENDORED_CSS};
11588
use crate::{
11689
test::{assert_cache_control, wrapper},
117-
web::{cache::CachePolicy, error::AxumNope},
90+
web::cache::CachePolicy,
11891
};
11992
use reqwest::StatusCode;
12093
use std::fs;
12194
use test_case::test_case;
12295

96+
const STATIC_SEARCH_PATHS: &[&str] = &["static", "vendor"];
97+
12398
#[test]
12499
fn style_css() {
125100
wrapper(|env| {
@@ -243,28 +218,4 @@ mod tests {
243218
Ok(())
244219
});
245220
}
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-
}
270221
}

0 commit comments

Comments
 (0)