Skip to main content

webserver_base/assets/
cache_buster.rs

1//! The runtime half of cache busting: read the manifest, set the headers.
2//!
3//! Nothing here touches the filesystem beyond one read at boot. Hashing is a
4//! build step — see [`generate`](super::generate) — so the server can run on a
5//! read-only image, and a restart cannot re-hash already-hashed files.
6
7use std::collections::BTreeMap;
8use std::fmt::{self, Display, Formatter};
9use std::path::{Path, PathBuf};
10
11use axum::body::Body;
12use axum::extract::Request;
13use axum::http::header::{
14    CACHE_CONTROL, ETAG, EXPIRES, IF_MATCH, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
15    IF_UNMODIFIED_SINCE, PRAGMA,
16};
17use axum::http::{HeaderMap, HeaderValue, StatusCode};
18use axum::middleware::Next;
19use axum::response::Response;
20use chrono::{DateTime, Duration, TimeDelta, Utc};
21use tracing::instrument;
22
23use super::error::CacheBusterError;
24use super::generate::STATIC_DIRECTORY;
25use super::manifest::{MANIFEST_PATH, Manifest};
26
27/// Resolves a logical asset path to its content-hashed one.
28#[derive(Debug, Clone, Default)]
29pub struct CacheBuster {
30    manifest: Manifest,
31    /// The directory the manifest was read from. Every hashed path is relative
32    /// to it on disk, and to `/` as a URL.
33    root: PathBuf,
34}
35
36impl CacheBuster {
37    /// A cache buster that knows about nothing, for a server with no static
38    /// assets at all.
39    #[must_use]
40    pub fn empty() -> Self {
41        Self::default()
42    }
43
44    /// Loads the manifest the build produced.
45    ///
46    /// A project with no `static/` gets an empty map and serves no `/static`
47    /// route. A project *with* `static/` but no manifest is a build that never
48    /// ran its asset step: every hashed URL would 404, so that is an error
49    /// rather than a silent degradation.
50    ///
51    /// # Errors
52    ///
53    /// [`CacheBusterError::MissingManifest`] if there are assets but no
54    /// manifest, or [`CacheBusterError::ParseManifest`] if it is malformed.
55    #[instrument(skip_all)]
56    pub fn load() -> Result<Self, CacheBusterError> {
57        Self::load_in(Path::new(""))
58    }
59
60    /// [`load`](Self::load), under `root`.
61    pub(crate) fn load_in(root: &Path) -> Result<Self, CacheBusterError> {
62        let root: PathBuf = root.to_path_buf();
63        if !root.join(STATIC_DIRECTORY).is_dir() {
64            return Ok(Self {
65                manifest: Manifest::default(),
66                root,
67            });
68        }
69        let manifest_path: PathBuf = root.join(MANIFEST_PATH);
70        if !manifest_path.is_file() {
71            return Err(CacheBusterError::MissingManifest {
72                path: manifest_path,
73            });
74        }
75        Ok(Self {
76            manifest: Manifest::load_in(&root)?,
77            root,
78        })
79    }
80
81    /// The directory every hashed path is relative to on disk.
82    pub(crate) fn root(&self) -> &Path {
83        &self.root
84    }
85
86    /// Where [`get_file`](Self::get_file)'s result lives on disk.
87    ///
88    /// The one place a URL path becomes a file path. A leading `/` is dropped
89    /// first, because joining an absolute path replaces the root outright.
90    pub(crate) fn file(&self, original: &str) -> PathBuf {
91        self.root
92            .join(self.get_file(original).trim_start_matches('/'))
93    }
94
95    /// The hashed path for `original`, or `original` itself when it is not a
96    /// hashed asset.
97    ///
98    /// Never fails: falling back is better than taking a page down over one
99    /// image.
100    #[must_use]
101    pub fn get_file(&self, original: &str) -> String {
102        self.manifest.resolve(original).to_string()
103    }
104
105    /// Whether `original` is a known hashed asset.
106    ///
107    /// The distinction [`get_file`](Self::get_file) cannot make: it returns the
108    /// input unchanged for anything it does not know, which is right for an
109    /// absolute URL and wrong for a typo. Boot-time validation needs to tell
110    /// those apart.
111    #[must_use]
112    pub fn is_hashed(&self, original: &str) -> bool {
113        self.manifest.contains(original)
114    }
115
116    /// The manifest itself.
117    #[must_use]
118    pub const fn manifest(&self) -> &Manifest {
119        &self.manifest
120    }
121
122    /// The whole map, as the templates see it.
123    #[must_use]
124    pub const fn cache(&self) -> &BTreeMap<String, String> {
125        self.manifest.entries()
126    }
127
128    /// Whether any asset is hashed.
129    #[must_use]
130    pub fn is_empty(&self) -> bool {
131        self.manifest.is_empty()
132    }
133
134    /// Marks a response as never cacheable.
135    ///
136    /// For everything whose URL does *not* carry a content hash: pages, API
137    /// responses, `robots.txt`. Conditional-request headers are stripped as
138    /// well, so an intermediary cannot revalidate its way back to a stale copy.
139    ///
140    /// # Errors
141    ///
142    /// Never; the signature matches axum's middleware shape.
143    #[instrument(skip_all)]
144    pub async fn never_cache_middleware(
145        request: Request,
146        next: Next,
147    ) -> Result<Response, StatusCode> {
148        let mut response: Response<Body> = next.run(request).await;
149        let headers: &mut HeaderMap = response.headers_mut();
150
151        remove_conditional_headers(headers);
152        headers.insert(
153            EXPIRES,
154            HeaderValue::from_static("Thu, 01 Jan 1970 00:00:00 GMT"),
155        );
156        headers.insert(
157            CACHE_CONTROL,
158            HeaderValue::from_static("no-cache, no-store, must-revalidate, private, max-age=0"),
159        );
160        headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
161
162        Ok(response)
163    }
164
165    /// Marks a response as immutable for a year.
166    ///
167    /// Only ever correct for content-hashed URLs, where a changed file is a
168    /// changed URL by construction. That invariant is why every byte under
169    /// `/static` is hashed, including the well-known icons.
170    ///
171    /// # Errors
172    ///
173    /// Never; the signature matches axum's middleware shape.
174    #[instrument(skip_all)]
175    pub async fn forever_cache_middleware(
176        request: Request,
177        next: Next,
178    ) -> Result<Response, StatusCode> {
179        let mut response: Response<Body> = next.run(request).await;
180        let headers: &mut HeaderMap = response.headers_mut();
181
182        remove_conditional_headers(headers);
183
184        let one_year: TimeDelta = Duration::days(365);
185        let expires: DateTime<Utc> = Utc::now() + one_year;
186        if let Ok(expires) = HeaderValue::from_str(&expires.to_rfc2822()) {
187            headers.insert(EXPIRES, expires);
188        }
189        headers.insert(
190            CACHE_CONTROL,
191            HeaderValue::from_static("public, max-age=31536000, must-revalidate, immutable"),
192        );
193
194        Ok(response)
195    }
196}
197
198impl Display for CacheBuster {
199    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
200        write!(f, "CacheBuster ({} entries):", self.manifest.len())?;
201        for (original, hashed) in self.manifest.entries() {
202            write!(f, "\n\t`{original}` -> `{hashed}`")?;
203        }
204        Ok(())
205    }
206}
207
208fn remove_conditional_headers(headers: &mut HeaderMap) {
209    headers.remove(ETAG);
210    headers.remove(IF_MODIFIED_SINCE);
211    headers.remove(IF_MATCH);
212    headers.remove(IF_NONE_MATCH);
213    headers.remove(IF_RANGE);
214    headers.remove(IF_UNMODIFIED_SINCE);
215}
216
217#[cfg(test)]
218mod tests {
219    use super::CacheBuster;
220
221    #[test]
222    fn an_empty_cache_buster_returns_paths_unchanged() {
223        let cache_buster: CacheBuster = CacheBuster::empty();
224
225        let expected: String = String::from("static/stylesheet/main.css");
226        let actual: String = cache_buster.get_file("static/stylesheet/main.css");
227        assert_eq!(expected, actual);
228
229        let expected: bool = true;
230        let actual: bool = cache_buster.is_empty();
231        assert_eq!(expected, actual);
232    }
233}