Skip to content

Commit 620d89c

Browse files
committed
add build-time ETag to /-/static/* responses & return "304 Not Modified"
1 parent b4e619d commit 620d89c

File tree

7 files changed

+423
-38
lines changed

7 files changed

+423
-38
lines changed

Cargo.lock

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ fn-error-context = "0.2.0"
102102
# Templating
103103
askama = "0.14.0"
104104
walkdir = "2"
105+
phf = "0.13.1"
105106

106107
# Date and Time utilities
107108
chrono = { version = "0.4.11", default-features = false, features = ["clock", "serde"] }
@@ -110,6 +111,7 @@ chrono = { version = "0.4.11", default-features = false, features = ["clock", "s
110111
thread_local = "1.1.3"
111112
constant_time_eq = "0.4.2"
112113
fastly-api = "12.0.0"
114+
md5 = "0.8.0"
113115

114116
[dev-dependencies]
115117
criterion = "0.7.0"
@@ -132,8 +134,10 @@ debug = "line-tables-only"
132134

133135
[build-dependencies]
134136
time = "0.3"
137+
md5 = "0.8.0"
135138
gix = { version = "0.74.0", default-features = false }
136139
string_cache_codegen = "0.6.1"
140+
phf_codegen = "0.13"
137141
walkdir = "2"
138142
anyhow = { version = "1.0.42", features = ["backtrace"] }
139143
grass = { version = "0.13.1", default-features = false }

build.rs

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::{Context as _, Error, Result};
2-
use std::{env, path::Path};
2+
use std::{env, fs::File, io::Write as _, path::Path};
33

44
mod tracked {
55
use std::{
@@ -68,13 +68,27 @@ mod tracked {
6868
}
6969
}
7070

71+
type ETagMap<'a> = phf_codegen::Map<'a, String>;
72+
7173
fn main() -> Result<()> {
7274
let out_dir = env::var("OUT_DIR").context("missing OUT_DIR")?;
7375
let out_dir = Path::new(&out_dir);
7476
read_git_version()?;
75-
compile_sass(out_dir)?;
77+
78+
let mut etag_map: ETagMap = ETagMap::new();
79+
80+
compile_sass(out_dir, &mut etag_map)?;
7681
write_known_targets(out_dir)?;
7782
compile_syntax(out_dir).context("could not compile syntax files")?;
83+
calculate_static_etags(&mut etag_map)?;
84+
85+
let mut etag_file = File::create(out_dir.join("static_etag_map.rs"))?;
86+
writeln!(
87+
&mut etag_file,
88+
"pub static STATIC_ETAG_MAP: ::phf::Map<&'static str, &'static str> = {};",
89+
etag_map.build()
90+
)?;
91+
etag_file.sync_all()?;
7892

7993
// trigger recompilation when a new migration is added
8094
println!("cargo:rerun-if-changed=migrations");
@@ -118,6 +132,16 @@ fn get_git_hash() -> Result<Option<String>> {
118132
}
119133
}
120134

135+
fn etag_from_path(path: impl AsRef<Path>) -> Result<String> {
136+
Ok(etag_from_content(std::fs::read(&path)?))
137+
}
138+
139+
fn etag_from_content(content: impl AsRef<[u8]>) -> String {
140+
let digest = md5::compute(content);
141+
let md5_hex = format!("{:x}", digest);
142+
format!(r#""\"{md5_hex}\"""#)
143+
}
144+
121145
fn compile_sass_file(src: &Path, dest: &Path) -> Result<()> {
122146
let css = grass::from_path(
123147
src.to_str()
@@ -133,7 +157,7 @@ fn compile_sass_file(src: &Path, dest: &Path) -> Result<()> {
133157
Ok(())
134158
}
135159

136-
fn compile_sass(out_dir: &Path) -> Result<()> {
160+
fn compile_sass(out_dir: &Path, etag_map: &mut ETagMap) -> Result<()> {
137161
const STYLE_DIR: &str = "templates/style";
138162

139163
for entry in walkdir::WalkDir::new(STYLE_DIR) {
@@ -146,12 +170,13 @@ fn compile_sass(out_dir: &Path) -> Result<()> {
146170
.to_str()
147171
.context("file name must be a utf-8 string")?;
148172
if !file_name.starts_with('_') {
149-
let dest = out_dir
150-
.join(entry.path().strip_prefix(STYLE_DIR)?)
151-
.with_extension("css");
173+
let dest = out_dir.join(file_name).with_extension("css");
152174
compile_sass_file(entry.path(), &dest).with_context(|| {
153175
format!("compiling {} to {}", entry.path().display(), dest.display())
154176
})?;
177+
178+
let dest_str = dest.file_name().unwrap().to_str().unwrap().to_owned();
179+
etag_map.entry(dest_str, etag_from_path(&dest)?);
155180
}
156181
}
157182
}
@@ -160,7 +185,32 @@ fn compile_sass(out_dir: &Path) -> Result<()> {
160185
let pure = tracked::read_to_string("vendor/pure-css/css/pure-min.css")?;
161186
let grids = tracked::read_to_string("vendor/pure-css/css/grids-responsive-min.css")?;
162187
let vendored = pure + &grids;
163-
std::fs::write(out_dir.join("vendored").with_extension("css"), vendored)?;
188+
std::fs::write(out_dir.join("vendored").with_extension("css"), &vendored)?;
189+
190+
etag_map.entry(
191+
"vendored.css".to_owned(),
192+
etag_from_content(vendored.as_bytes()),
193+
);
194+
195+
Ok(())
196+
}
197+
198+
fn calculate_static_etags(etag_map: &mut ETagMap) -> Result<()> {
199+
const STATIC_DIRS: &[&str] = &["static", "vendor"];
200+
201+
for static_dir in STATIC_DIRS {
202+
for entry in walkdir::WalkDir::new(static_dir) {
203+
let entry = entry?;
204+
let path = entry.path();
205+
if !path.is_file() {
206+
continue;
207+
}
208+
209+
let partial_path = path.strip_prefix(static_dir).unwrap();
210+
let partial_path_str = partial_path.to_string_lossy().to_string();
211+
etag_map.entry(partial_path_str, etag_from_path(path)?);
212+
}
213+
}
164214

165215
Ok(())
166216
}

src/db/mimes.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ macro_rules! mime {
1010
mime!(APPLICATION_ZIP, "application/zip");
1111
mime!(APPLICATION_ZSTD, "application/zstd");
1212
mime!(APPLICATION_GZIP, "application/gzip");
13+
mime!(
14+
APPLICATION_OPENSEARCH_XML,
15+
"application/opensearchdescription+xml"
16+
);
1317
mime!(APPLICATION_XML, "application/xml");
1418
mime!(TEXT_MARKDOWN, "text/markdown");
1519
mime!(TEXT_RUST, "text/rust");

src/web/headers/if_none_match.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//! Adapted version of `headers::IfNoneMatch`.
2+
//!
3+
//! The combination of `TypedHeader` and `IfNoneMatch` works in odd ways.
4+
//! They are built in a way that a _missing_ `If-None-Match` header will lead to:
5+
//!
6+
//! 1. extractor with `TypedHeader<IfNoneMatch>` returning `IfNoneMatch("")`
7+
//! 2. extractor with `Option<TypedHeader<IfNoneMatch>>` returning `Some(IfNoneMatch(""))`
8+
//!
9+
//! Where I would expect:
10+
//! 1. a failure because of the missing header
11+
//! 2. `None` for the missing header
12+
//!
13+
//! This could be solved by either adapting `TypedHeader` or `IfNoneMatch`, I'm not sure which is
14+
//! right.
15+
//!
16+
//! Some reading material for those interested:
17+
//! * https://github.com/hyperium/headers/issues/204
18+
//! * https://github.com/hyperium/headers/pull/165
19+
//! * https://github.com/tokio-rs/axum/issues/1781
20+
//! * https://github.com/tokio-rs/axum/pull/1810
21+
//! * https://github.com/tokio-rs/axum/pull/2475
22+
//!
23+
//! Right now I feel like adapting `IfNoneMatch` is the "most correct-ish" option.
24+
25+
#[allow(clippy::disallowed_types)]
26+
mod header_impl {
27+
use axum_extra::headers::{self, ETag, Header, IfNoneMatch as OriginalIfNoneMatch};
28+
use derive_more::Deref;
29+
30+
#[derive(Debug, Clone, PartialEq, Deref)]
31+
pub(crate) struct IfNoneMatch(pub axum_extra::headers::IfNoneMatch);
32+
33+
impl Header for IfNoneMatch {
34+
fn name() -> &'static http::HeaderName {
35+
OriginalIfNoneMatch::name()
36+
}
37+
38+
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
39+
where
40+
Self: Sized,
41+
I: Iterator<Item = &'i http::HeaderValue>,
42+
{
43+
let mut values = values.peekable();
44+
45+
// NOTE: this is the difference to the original implementation.
46+
// When there is no header in the request, I want the decoding to fail.
47+
// This makes Option<TypedHeader<H>> return `None`, and also matches
48+
// most other header implementations.
49+
if values.peek().is_none() {
50+
Err(headers::Error::invalid())
51+
} else {
52+
OriginalIfNoneMatch::decode(&mut values).map(IfNoneMatch)
53+
}
54+
}
55+
56+
fn encode<E: Extend<http::HeaderValue>>(&self, values: &mut E) {
57+
self.0.encode(values)
58+
}
59+
}
60+
61+
impl From<ETag> for IfNoneMatch {
62+
fn from(value: ETag) -> Self {
63+
Self(value.into())
64+
}
65+
}
66+
}
67+
68+
pub(crate) use header_impl::IfNoneMatch;
69+
70+
#[cfg(test)]
71+
mod tests {
72+
use super::*;
73+
use anyhow::Result;
74+
use axum::{RequestPartsExt, body::Body, extract::Request};
75+
use axum_extra::{
76+
TypedHeader,
77+
headers::{ETag, HeaderMapExt as _},
78+
};
79+
use http::{HeaderMap, request};
80+
81+
fn parts(if_none_match: Option<IfNoneMatch>) -> request::Parts {
82+
let mut builder = Request::builder();
83+
84+
if let Some(if_none_match) = if_none_match {
85+
let headers = builder.headers_mut().unwrap();
86+
headers.typed_insert(if_none_match.clone());
87+
}
88+
89+
let (parts, _body) = builder.uri("/").body(Body::empty()).unwrap().into_parts();
90+
91+
parts
92+
}
93+
94+
fn example_header() -> IfNoneMatch {
95+
IfNoneMatch::from("\"some-etag-value\"".parse::<ETag>().unwrap())
96+
}
97+
98+
#[test]
99+
fn test_normal_typed_get_with_empty_headers() {
100+
let map = HeaderMap::new();
101+
assert!(map.typed_get::<IfNoneMatch>().is_none());
102+
assert!(map.typed_try_get::<IfNoneMatch>().unwrap().is_none());
103+
}
104+
105+
#[test]
106+
fn test_normal_typed_get_with_value_headers() -> Result<()> {
107+
let if_none_match = example_header();
108+
109+
let mut map = HeaderMap::new();
110+
map.typed_insert(if_none_match.clone());
111+
112+
assert_eq!(map.typed_get::<IfNoneMatch>(), Some(if_none_match.clone()));
113+
assert_eq!(map.typed_try_get::<IfNoneMatch>()?, Some(if_none_match));
114+
115+
Ok(())
116+
}
117+
118+
#[tokio::test]
119+
async fn test_extract_from_empty_request_via_optional_typed_header() -> Result<()> {
120+
let mut parts = parts(None);
121+
122+
assert!(
123+
parts
124+
.extract::<Option<TypedHeader<IfNoneMatch>>>()
125+
.await?
126+
// this is what we want, and the default `headers::IfNoneMatch` header can't
127+
// offer. Or the impl of the `TypedHeader` extractor, depending on
128+
// interpretation.
129+
.is_none()
130+
);
131+
132+
Ok(())
133+
}
134+
135+
#[tokio::test]
136+
async fn test_extract_from_empty_request_via_mandatory_typed_header() -> Result<()> {
137+
let mut parts = parts(None);
138+
139+
// mandatory extractor leads to error when the header is missing.
140+
assert!(parts.extract::<TypedHeader<IfNoneMatch>>().await.is_err());
141+
142+
Ok(())
143+
}
144+
145+
#[tokio::test]
146+
async fn test_extract_from_header_via_optional_typed_header() -> Result<()> {
147+
let if_none_match = example_header();
148+
let mut parts = parts(Some(if_none_match.clone()));
149+
150+
assert_eq!(
151+
parts
152+
.extract::<Option<TypedHeader<IfNoneMatch>>>()
153+
.await?
154+
.map(|th| th.0),
155+
Some(if_none_match)
156+
);
157+
158+
Ok(())
159+
}
160+
161+
#[tokio::test]
162+
async fn test_extract_from_header_via_mandatory_typed_header() -> Result<()> {
163+
let if_none_match = example_header();
164+
let mut parts = parts(Some(if_none_match.clone()));
165+
166+
assert_eq!(
167+
parts.extract::<TypedHeader<IfNoneMatch>>().await?.0,
168+
if_none_match
169+
);
170+
171+
Ok(())
172+
}
173+
}

0 commit comments

Comments
 (0)