use std::collections::BTreeMap;
use std::fmt::{self, Display, Formatter};
use std::path::{Path, PathBuf};
use axum::body::Body;
use axum::extract::Request;
use axum::http::header::{
CACHE_CONTROL, ETAG, EXPIRES, IF_MATCH, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
IF_UNMODIFIED_SINCE, PRAGMA,
};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use chrono::{DateTime, Duration, TimeDelta, Utc};
use tracing::instrument;
use super::error::CacheBusterError;
use super::generate::STATIC_DIRECTORY;
use super::manifest::{MANIFEST_PATH, Manifest};
#[derive(Debug, Clone, Default)]
pub struct CacheBuster {
manifest: Manifest,
root: PathBuf,
}
impl CacheBuster {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[instrument(skip_all)]
pub fn load() -> Result<Self, CacheBusterError> {
Self::load_in(Path::new(""))
}
pub(crate) fn load_in(root: &Path) -> Result<Self, CacheBusterError> {
let root: PathBuf = root.to_path_buf();
if !root.join(STATIC_DIRECTORY).is_dir() {
return Ok(Self {
manifest: Manifest::default(),
root,
});
}
let manifest_path: PathBuf = root.join(MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(CacheBusterError::MissingManifest {
path: manifest_path,
});
}
Ok(Self {
manifest: Manifest::load_in(&root)?,
root,
})
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn file(&self, original: &str) -> PathBuf {
self.root
.join(self.get_file(original).trim_start_matches('/'))
}
#[must_use]
pub fn get_file(&self, original: &str) -> String {
self.manifest.resolve(original).to_string()
}
#[must_use]
pub fn is_hashed(&self, original: &str) -> bool {
self.manifest.contains(original)
}
#[must_use]
pub const fn manifest(&self) -> &Manifest {
&self.manifest
}
#[must_use]
pub const fn cache(&self) -> &BTreeMap<String, String> {
self.manifest.entries()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.manifest.is_empty()
}
#[instrument(skip_all)]
pub async fn never_cache_middleware(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let mut response: Response<Body> = next.run(request).await;
let headers: &mut HeaderMap = response.headers_mut();
remove_conditional_headers(headers);
headers.insert(
EXPIRES,
HeaderValue::from_static("Thu, 01 Jan 1970 00:00:00 GMT"),
);
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-store, must-revalidate, private, max-age=0"),
);
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
Ok(response)
}
#[instrument(skip_all)]
pub async fn forever_cache_middleware(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let mut response: Response<Body> = next.run(request).await;
let headers: &mut HeaderMap = response.headers_mut();
remove_conditional_headers(headers);
let one_year: TimeDelta = Duration::days(365);
let expires: DateTime<Utc> = Utc::now() + one_year;
if let Ok(expires) = HeaderValue::from_str(&expires.to_rfc2822()) {
headers.insert(EXPIRES, expires);
}
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, must-revalidate, immutable"),
);
Ok(response)
}
}
impl Display for CacheBuster {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "CacheBuster ({} entries):", self.manifest.len())?;
for (original, hashed) in self.manifest.entries() {
write!(f, "\n\t`{original}` -> `{hashed}`")?;
}
Ok(())
}
}
fn remove_conditional_headers(headers: &mut HeaderMap) {
headers.remove(ETAG);
headers.remove(IF_MODIFIED_SINCE);
headers.remove(IF_MATCH);
headers.remove(IF_NONE_MATCH);
headers.remove(IF_RANGE);
headers.remove(IF_UNMODIFIED_SINCE);
}
#[cfg(test)]
mod tests {
use super::CacheBuster;
#[test]
fn an_empty_cache_buster_returns_paths_unchanged() {
let cache_buster: CacheBuster = CacheBuster::empty();
let expected: String = String::from("static/stylesheet/main.css");
let actual: String = cache_buster.get_file("static/stylesheet/main.css");
assert_eq!(expected, actual);
let expected: bool = true;
let actual: bool = cache_buster.is_empty();
assert_eq!(expected, actual);
}
}