use std::time::Duration; use axum::response::{IntoResponse, Response}; use axum_extra::{TypedHeader, headers}; use sha2::{Digest, Sha256}; use wastebin_highlight::Theme; /// An asset associated with a MIME type. #[derive(Clone)] pub(crate) struct Asset { /// Route that this will be served under. pub route: String, /// MIME type of this asset determined for the `ContentType` response header. mime: mime::Mime, /// Actual asset content. content: Vec, } /// Asset kind. #[derive(Copy, Clone)] pub(crate) enum Kind { Css, Js, } impl IntoResponse for Asset { fn into_response(self) -> Response { let content_type_header = headers::ContentType::from(self.mime); let headers = ( TypedHeader(content_type_header), TypedHeader( headers::CacheControl::new() .with_max_age(Duration::from_secs(60 * 60 * 24 * 30)) .with_immutable(), ), ); (headers, self.content).into_response() } } impl Asset { /// Construct new asset under the given `name`, `mime` type and `content`. pub fn new(name: &str, mime: mime::Mime, content: Vec) -> Self { Self { route: format!("/{name}"), mime, content, } } /// Construct new hashed asset under the given `name`, `kind` and `content`. pub fn new_hashed(name: &str, kind: Kind, content: Vec) -> Self { let (mime, ext) = match kind { Kind::Css => (mime::TEXT_CSS, "css"), Kind::Js => (mime::TEXT_JAVASCRIPT, "js"), }; let route = format!( "/{name}.{}.{ext}", hex::encode(Sha256::digest(&content)) .get(0..16) .expect("at least 16 characters") ); Self { route, mime, content, } } pub fn route(&self) -> &str { &self.route } } /// Collection of light and dark CSS and main UI style CSS derived from them. pub(crate) struct Css { /// Main UI CSS stylesheet. pub style: Asset, /// Light theme colors. pub light: Asset, /// Dark theme colors. pub dark: Asset, /// Overrides applied when JavaScript is disabled. pub no_js: Asset, } impl Css { /// Create CSS assets for `theme`. pub fn new(theme: Theme) -> Self { let style = Asset::new_hashed("style", Kind::Css, include_str!("style.css").into()); let light = Asset::new_hashed("light", Kind::Css, theme.light_css()); let dark = Asset::new_hashed("dark", Kind::Css, theme.dark_css()); let no_js = Asset::new_hashed("no-js", Kind::Css, include_str!("no-js.css").into()); Self { style, light, dark, no_js, } } } #[cfg(test)] mod tests { use super::*; #[test] fn hashed_asset() { let asset = Asset::new_hashed("style", Kind::Css, String::from("body {}").into_bytes()); assert_eq!(asset.route, "/style.62368a1a29259b30.css"); let asset = Asset::new_hashed("main", Kind::Js, String::from("1 + 1").into_bytes()); assert_eq!(asset.route, "/main.72fce59447a01f48.js"); } #[test] fn asset_response() { let asset = Asset::new( "foo.css", mime::TEXT_CSS, String::from("body {}").into_bytes(), ); let response = asset.into_response(); let headers = response.headers(); assert_eq!(headers.get(http::header::CONTENT_TYPE).unwrap(), "text/css"); assert_eq!( headers.get(http::header::CACHE_CONTROL).unwrap(), "immutable, max-age=2592000" ); } }