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}
32
33impl CacheBuster {
34    /// A cache buster that knows about nothing, for a server with no static
35    /// assets at all.
36    #[must_use]
37    pub fn empty() -> Self {
38        Self::default()
39    }
40
41    /// Loads the manifest the build produced.
42    ///
43    /// A project with no `static/` gets an empty map and serves no `/static`
44    /// route. A project *with* `static/` but no manifest is a build that never
45    /// ran its asset step: every hashed URL would 404, so that is an error
46    /// rather than a silent degradation.
47    ///
48    /// # Errors
49    ///
50    /// [`CacheBusterError::MissingManifest`] if there are assets but no
51    /// manifest, or [`CacheBusterError::ParseManifest`] if it is malformed.
52    #[instrument(skip_all)]
53    pub fn load() -> Result<Self, CacheBusterError> {
54        if !Path::new(STATIC_DIRECTORY).is_dir() {
55            return Ok(Self::empty());
56        }
57        if !Path::new(MANIFEST_PATH).is_file() {
58            return Err(CacheBusterError::MissingManifest {
59                path: PathBuf::from(MANIFEST_PATH),
60            });
61        }
62        Ok(Self {
63            manifest: Manifest::load()?,
64        })
65    }
66
67    /// The hashed path for `original`, or `original` itself when it is not a
68    /// hashed asset.
69    ///
70    /// Never fails: falling back is better than taking a page down over one
71    /// image.
72    #[must_use]
73    pub fn get_file(&self, original: &str) -> String {
74        self.manifest.resolve(original).to_string()
75    }
76
77    /// Whether `original` is a known hashed asset.
78    ///
79    /// The distinction [`get_file`](Self::get_file) cannot make: it returns the
80    /// input unchanged for anything it does not know, which is right for an
81    /// absolute URL and wrong for a typo. Boot-time validation needs to tell
82    /// those apart.
83    #[must_use]
84    pub fn is_hashed(&self, original: &str) -> bool {
85        self.manifest.contains(original)
86    }
87
88    /// The manifest itself.
89    #[must_use]
90    pub const fn manifest(&self) -> &Manifest {
91        &self.manifest
92    }
93
94    /// The whole map, as the templates see it.
95    #[must_use]
96    pub const fn cache(&self) -> &BTreeMap<String, String> {
97        self.manifest.entries()
98    }
99
100    /// Whether any asset is hashed.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.manifest.is_empty()
104    }
105
106    /// Marks a response as never cacheable.
107    ///
108    /// For everything whose URL does *not* carry a content hash: pages, API
109    /// responses, `robots.txt`. Conditional-request headers are stripped as
110    /// well, so an intermediary cannot revalidate its way back to a stale copy.
111    ///
112    /// # Errors
113    ///
114    /// Never; the signature matches axum's middleware shape.
115    #[instrument(skip_all)]
116    pub async fn never_cache_middleware(
117        request: Request,
118        next: Next,
119    ) -> Result<Response, StatusCode> {
120        let mut response: Response<Body> = next.run(request).await;
121        let headers: &mut HeaderMap = response.headers_mut();
122
123        remove_conditional_headers(headers);
124        headers.insert(
125            EXPIRES,
126            HeaderValue::from_static("Thu, 01 Jan 1970 00:00:00 GMT"),
127        );
128        headers.insert(
129            CACHE_CONTROL,
130            HeaderValue::from_static("no-cache, no-store, must-revalidate, private, max-age=0"),
131        );
132        headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
133
134        Ok(response)
135    }
136
137    /// Marks a response as immutable for a year.
138    ///
139    /// Only ever correct for content-hashed URLs, where a changed file is a
140    /// changed URL by construction. That invariant is why every byte under
141    /// `/static` is hashed, including the well-known icons.
142    ///
143    /// # Errors
144    ///
145    /// Never; the signature matches axum's middleware shape.
146    #[instrument(skip_all)]
147    pub async fn forever_cache_middleware(
148        request: Request,
149        next: Next,
150    ) -> Result<Response, StatusCode> {
151        let mut response: Response<Body> = next.run(request).await;
152        let headers: &mut HeaderMap = response.headers_mut();
153
154        remove_conditional_headers(headers);
155
156        let one_year: TimeDelta = Duration::days(365);
157        let expires: DateTime<Utc> = Utc::now() + one_year;
158        if let Ok(expires) = HeaderValue::from_str(&expires.to_rfc2822()) {
159            headers.insert(EXPIRES, expires);
160        }
161        headers.insert(
162            CACHE_CONTROL,
163            HeaderValue::from_static("public, max-age=31536000, must-revalidate, immutable"),
164        );
165
166        Ok(response)
167    }
168}
169
170impl Display for CacheBuster {
171    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
172        write!(f, "CacheBuster ({} entries):", self.manifest.len())?;
173        for (original, hashed) in self.manifest.entries() {
174            write!(f, "\n\t`{original}` -> `{hashed}`")?;
175        }
176        Ok(())
177    }
178}
179
180fn remove_conditional_headers(headers: &mut HeaderMap) {
181    headers.remove(ETAG);
182    headers.remove(IF_MODIFIED_SINCE);
183    headers.remove(IF_MATCH);
184    headers.remove(IF_NONE_MATCH);
185    headers.remove(IF_RANGE);
186    headers.remove(IF_UNMODIFIED_SINCE);
187}
188
189#[cfg(test)]
190mod tests {
191    use super::CacheBuster;
192
193    #[test]
194    fn an_empty_cache_buster_returns_paths_unchanged() {
195        let cache_buster: CacheBuster = CacheBuster::empty();
196
197        let expected: String = String::from("static/stylesheet/main.css");
198        let actual: String = cache_buster.get_file("static/stylesheet/main.css");
199        assert_eq!(expected, actual);
200
201        let expected: bool = true;
202        let actual: bool = cache_buster.is_empty();
203        assert_eq!(expected, actual);
204    }
205}