webserver_base/assets/
cache_buster.rs1use 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#[derive(Debug, Clone, Default)]
29pub struct CacheBuster {
30 manifest: Manifest,
31 root: PathBuf,
34}
35
36impl CacheBuster {
37 #[must_use]
40 pub fn empty() -> Self {
41 Self::default()
42 }
43
44 #[instrument(skip_all)]
56 pub fn load() -> Result<Self, CacheBusterError> {
57 Self::load_in(Path::new(""))
58 }
59
60 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 pub(crate) fn root(&self) -> &Path {
83 &self.root
84 }
85
86 pub(crate) fn file(&self, original: &str) -> PathBuf {
91 self.root
92 .join(self.get_file(original).trim_start_matches('/'))
93 }
94
95 #[must_use]
101 pub fn get_file(&self, original: &str) -> String {
102 self.manifest.resolve(original).to_string()
103 }
104
105 #[must_use]
112 pub fn is_hashed(&self, original: &str) -> bool {
113 self.manifest.contains(original)
114 }
115
116 #[must_use]
118 pub const fn manifest(&self) -> &Manifest {
119 &self.manifest
120 }
121
122 #[must_use]
124 pub const fn cache(&self) -> &BTreeMap<String, String> {
125 self.manifest.entries()
126 }
127
128 #[must_use]
130 pub fn is_empty(&self) -> bool {
131 self.manifest.is_empty()
132 }
133
134 #[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 #[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}