diff --git a/Cargo.lock b/Cargo.lock index 7c552bde3..76dbd34a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,6 +1414,7 @@ dependencies = [ "getrandom 0.2.10", "gix", "grass", + "hex", "hostname", "http", "humantime", @@ -1423,6 +1424,7 @@ dependencies = [ "kuchiki", "log", "lol_html", + "md-5", "memmap2", "mime", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index 91ecc7bb9..9a735ca7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,8 @@ uuid = "1.1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" memmap2 = "0.5.0" +md-5 = "0.10.5" +hex = "0.4.3" # axum dependencies axum = { version = "0.6.1", features = ["headers"]} diff --git a/src/web/cache.rs b/src/web/cache.rs index cd05eee37..17205fb22 100644 --- a/src/web/cache.rs +++ b/src/web/cache.rs @@ -1,9 +1,17 @@ -use crate::config::Config; +use crate::{config::Config, web::error::AxumResult}; +use anyhow::Context as _; use axum::{ - http::Request as AxumHttpRequest, middleware::Next, response::Response as AxumResponse, + headers::{ETag, HeaderMapExt, IfNoneMatch}, + http::{Request as AxumHttpRequest, StatusCode}, + middleware::Next, + response::IntoResponse, + response::Response as AxumResponse, + TypedHeader, }; use http::{header::CACHE_CONTROL, HeaderValue}; +use md5::{Digest, Md5}; use std::sync::Arc; +use tracing::trace; pub static NO_CACHING: HeaderValue = HeaderValue::from_static("max-age=0"); @@ -101,6 +109,62 @@ pub(crate) async fn cache_middleware(req: AxumHttpRequest, next: Next) response } +pub(crate) async fn etag_middleware( + if_none_match: Option>, + req: AxumHttpRequest, + next: Next, +) -> AxumResult { + let if_none_match = if_none_match.map(|header| header.0); + let response = next.run(req).await; + + let (mut parts, body) = response.into_parts(); + + if let Some(etag) = parts.headers.typed_get::() { + // when the handler already set an ETag, use that one. + // can be used to generate an ETag not based on the content, but + // on other metadata. + // We also don't need to hash the response body then. + if let Some(if_none_match) = if_none_match { + if !if_none_match.precondition_passes(&etag) { + return Ok((StatusCode::NOT_MODIFIED, parts).into_response()); + } + } + Ok((parts, body).into_response()) + } else { + // when the response doesn't have an ETag, we want to generate one, + // which we need the body for. + let bytes = hyper::body::to_bytes(body) + .await + .context("could not read response body")?; + + if bytes.is_empty() { + Ok(parts.into_response()) + } else { + let mut hasher = Md5::new(); + hasher.update(&bytes); + let result = hasher.finalize(); + + let etag: ETag = format!("\"{}\"", hex::encode(result)).parse().expect( + "this is safe since the format! & hex::encode always is a valid ETag header", + ); + parts.headers.typed_insert(etag.clone()); + + if let Some(if_none_match) = if_none_match { + let etag_matches_if_none_match = !if_none_match.precondition_passes(&etag); + + trace!(?etag, etag_matches_if_none_match, ?if_none_match); + + if etag_matches_if_none_match { + return Ok((StatusCode::NOT_MODIFIED, parts).into_response()); + } + } + trace!(?etag, "no if-none-match header or no match"); + + Ok((parts, bytes).into_response()) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/web/mod.rs b/src/web/mod.rs index a4db97c33..f83cd093d 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -293,6 +293,7 @@ fn apply_middleware( .layer(Extension(context.storage()?)) .layer(Extension(context.repository_stats_updater()?)) .layer(option_layer(template_data.map(Extension))) + .layer(middleware::from_fn(cache::etag_middleware)) .layer(middleware::from_fn(csp::csp_middleware)) .layer(option_layer(has_templates.then_some(middleware::from_fn( page::web_page::render_templates_middleware,