umbral_cache/cache_page.rs
1//! View-level caching middleware — the Rust equivalent of Django's
2//! `@cache_page(seconds)` decorator.
3//!
4//! Wrap a [`Router`] subtree with [`cache_page`] and every eligible
5//! `GET` or `HEAD` response for that subtree is cached for the
6//! configured TTL. Subsequent requests for the same URI + query string
7//! get the cached response without hitting the handler.
8//!
9//! ```ignore
10//! use umbral_cache::cache_page;
11//! use std::time::Duration;
12//!
13//! let public = Router::new()
14//! .route("/", get(home))
15//! .route("/about", get(about))
16//! .layer(cache_page(Duration::from_secs(60)));
17//! ```
18//!
19//! ## Cache key
20//!
21//! `cache:page:GET:<host>:/path?query` — method + Host header + full URI
22//! including query string. Fragments are stripped by the browser and never
23//! reach the server. Including the Host header prevents multi-tenant
24//! cache-poisoning where tenant A's cached page would otherwise be served to
25//! requests arriving on a different Host.
26//!
27//! ## What gets cached
28//!
29//! Only `GET` and `HEAD` responses with HTTP status **200** are stored.
30//! The following bypass caching:
31//! - Any method other than `GET` / `HEAD` (POST, PUT, PATCH, DELETE).
32//! - Status code other than 200.
33//! - Response carries `Cache-Control: no-store`.
34//! - Response carries a `Set-Cookie` header (the body may be personalised).
35//! - Request carries an `umbral_session` cookie — personalised / logged-in
36//! requests are neither served from nor written to the page cache, keeping
37//! the cache to the safe anonymous-only subset.
38//!
39//! ## Ambient cache dependency
40//!
41//! [`cache_page`] reads the ambient [`super::Cache`] via [`super::ambient()`].
42//! If the ambient cache has not been initialised (i.e. [`super::CachePlugin::init`]
43//! has not been called), cache misses and stores are silently skipped —
44//! the handler always fires normally. This is intentional: a misconfigured
45//! cache degrades gracefully rather than returning 500s.
46//!
47//! ## Deferred
48//!
49//! - ETag / 304 conditional caching — the current implementation always
50//! serves the full cached body. A future iteration will store and compare
51//! ETags to emit 304 Not Modified, saving bandwidth.
52//! - Vary-header awareness (`Vary: Accept-Language`, etc.).
53//! - Per-route cache key prefix customisation.
54
55use std::sync::Arc;
56use std::task::{Context, Poll};
57use std::time::Duration;
58
59use axum::body::Body;
60use axum::http::{Method, Request, Response, StatusCode, header};
61use bytes::Bytes;
62use futures_util::future::BoxFuture;
63use http_body_util::BodyExt;
64use tower::{Layer, Service};
65
66use crate::Cache;
67
68// ── Public constructor ───────────────────────────────────────────────────────
69
70/// Return a [`CachePageLayer`] that caches eligible `GET`/`HEAD` responses
71/// for `ttl`.
72///
73/// Mount it with `Router::layer(cache_page(Duration::from_secs(60)))`.
74pub fn cache_page(ttl: Duration) -> CachePageLayer {
75 CachePageLayer { ttl, cache: None }
76}
77
78// ── Layer ────────────────────────────────────────────────────────────────────
79
80/// [`tower::Layer`] returned by [`cache_page`]. Wraps the inner service
81/// with [`CachePageService`].
82#[derive(Clone)]
83pub struct CachePageLayer {
84 ttl: Duration,
85 // An explicit cache can be injected for testing; production code
86 // reads the ambient handle via `crate::ambient()`.
87 cache: Option<Arc<Cache>>,
88}
89
90impl CachePageLayer {
91 /// Override the cache handle used by this layer. Useful in tests
92 /// where the ambient cache isn't initialised.
93 pub fn with_cache(mut self, cache: Cache) -> Self {
94 self.cache = Some(Arc::new(cache));
95 self
96 }
97}
98
99impl<S> Layer<S> for CachePageLayer {
100 type Service = CachePageService<S>;
101
102 fn layer(&self, inner: S) -> Self::Service {
103 CachePageService {
104 inner,
105 ttl: self.ttl,
106 cache: self.cache.clone(),
107 }
108 }
109}
110
111// ── Service ──────────────────────────────────────────────────────────────────
112
113/// [`tower::Service`] produced by [`CachePageLayer`].
114#[derive(Clone)]
115pub struct CachePageService<S> {
116 inner: S,
117 ttl: Duration,
118 cache: Option<Arc<Cache>>,
119}
120
121impl<S> Service<Request<Body>> for CachePageService<S>
122where
123 S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
124 S::Future: Send + 'static,
125 S::Error: Send + 'static,
126{
127 type Response = Response<Body>;
128 type Error = S::Error;
129 type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
130
131 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
132 self.inner.poll_ready(cx)
133 }
134
135 fn call(&mut self, req: Request<Body>) -> Self::Future {
136 let mut inner = self.inner.clone();
137 let ttl = self.ttl;
138 let explicit_cache = self.cache.clone();
139
140 Box::pin(async move {
141 // Only attempt to cache GET and HEAD
142 let method = req.method().clone();
143 if method != Method::GET && method != Method::HEAD {
144 return inner.call(req).await;
145 }
146
147 // Bypass for personalised / authenticated requests: if the incoming
148 // request carries an `umbral_session` cookie the response is user-
149 // specific and must not be served from or stored in the page cache.
150 // We match the literal cookie name "umbral_session" (the canonical
151 // name from umbral-sessions::COOKIE_NAME) without importing that crate
152 // to avoid a plugin-to-plugin dependency.
153 if request_has_session_cookie(&req) {
154 return inner.call(req).await;
155 }
156
157 // Build the cache key from method + Host header + full URI (path + query).
158 // Including the Host prevents multi-tenant cache-poisoning where different
159 // virtual hosts serving different content share cache entries.
160 let host = req
161 .headers()
162 .get(header::HOST)
163 .and_then(|v| v.to_str().ok())
164 .unwrap_or("")
165 .to_owned();
166 let uri = req.uri().to_string();
167 let cache_key = format!("cache:page:{}:{}:{}", method, host, uri);
168
169 // Resolve the cache to use: explicit (test injection) > ambient
170 let cache: Option<&Cache> = if let Some(ref c) = explicit_cache {
171 Some(c.as_ref())
172 } else {
173 crate::ambient()
174 };
175
176 // Cache hit — return the stored response bytes
177 if let Some(cache) = cache {
178 if let Some(stored) = cache.get_bytes_raw(&cache_key).await {
179 if let Ok(resp) = deserialise_cached_response(stored) {
180 return Ok(resp);
181 }
182 // Deserialisation failure → treat as a miss and re-run the handler
183 }
184 }
185
186 // Cache miss — call through to the handler
187 let resp = inner.call(req).await?;
188
189 // Only cache eligible responses
190 let status = resp.status();
191 if status != StatusCode::OK {
192 return Ok(resp);
193 }
194
195 let should_skip = response_bypasses_cache(&resp);
196
197 // Collect the body so we can both cache and return it.
198 // This buffers the full response in memory which is fine
199 // for HTML pages (< a few MB). Skip caching if collection
200 // fails but still return the original error to the client.
201 let (parts, body) = resp.into_parts();
202 let body_bytes = match body.collect().await {
203 Ok(collected) => collected.to_bytes(),
204 Err(e) => {
205 // BROKEN-7: the body stream failed partway. Reusing the
206 // success `parts` with an empty body fabricates a 200
207 // whose `Content-Length` no longer matches the (empty)
208 // body — that desyncs keep-alive connections and is
209 // indistinguishable from a real empty page. Log it and
210 // return a clean 502 instead; never cache it.
211 tracing::error!(
212 error = %e,
213 "cache_page: failed to collect upstream response body; returning 502"
214 );
215 let mut resp = Response::new(Body::from("Bad Gateway"));
216 *resp.status_mut() = StatusCode::BAD_GATEWAY;
217 return Ok(resp);
218 }
219 };
220
221 if !should_skip {
222 if let Some(cache) = explicit_cache.as_deref().or_else(|| crate::ambient()) {
223 let serialised = serialise_cached_response(&parts, &body_bytes);
224 cache.set_bytes_raw(&cache_key, serialised, Some(ttl)).await;
225 }
226 }
227
228 let resp = Response::from_parts(parts, Body::from(body_bytes));
229 Ok(resp)
230 })
231 }
232}
233
234// ── Helpers ──────────────────────────────────────────────────────────────────
235
236/// Return `true` when the request carries an `umbral_session` cookie.
237///
238/// Session-cookie-bearing requests are for authenticated / personalised pages.
239/// Serving those from (or caching them into) the shared page cache would either
240/// leak one user's content to another user or serve a stale anonymous page to a
241/// logged-in user. We bypass the cache entirely for these requests.
242///
243/// The cookie name `umbral_session` matches `umbral_sessions::COOKIE_NAME`. We
244/// match the literal string to avoid a crate dependency from umbral-cache on
245/// umbral-sessions.
246fn request_has_session_cookie<B>(req: &Request<B>) -> bool {
247 // Cookie header value is a semicolon-separated list of "name=value" pairs.
248 req.headers()
249 .get(header::COOKIE)
250 .and_then(|v| v.to_str().ok())
251 .map(|cookie_str| {
252 cookie_str
253 .split(';')
254 .any(|pair| pair.trim().starts_with("umbral_session="))
255 })
256 .unwrap_or(false)
257}
258
259/// Return `true` when the response should not be cached:
260/// - `Cache-Control: no-store` is present
261/// - `Set-Cookie` header is present
262fn response_bypasses_cache<B>(resp: &Response<B>) -> bool {
263 let headers = resp.headers();
264
265 // Cache-Control: no-store
266 if let Some(cc) = headers.get(header::CACHE_CONTROL) {
267 if cc
268 .to_str()
269 .unwrap_or("")
270 .split(',')
271 .any(|d| d.trim().eq_ignore_ascii_case("no-store"))
272 {
273 return true;
274 }
275 }
276
277 // Any Set-Cookie header means the response is personalised
278 if headers.contains_key(header::SET_COOKIE) {
279 return true;
280 }
281
282 false
283}
284
285// ── Wire format for cached responses ─────────────────────────────────────────
286//
287// Stored bytes layout (length-prefixed, little-endian u32):
288// [4 bytes: header_count N]
289// for each header:
290// [4 bytes: name_len][name bytes][4 bytes: value_len][value bytes]
291// [body bytes]
292//
293// This is a simple custom format; serde/JSON would add overhead for the
294// binary body. Status code is always 200 (the only value we cache) so
295// it's not stored.
296
297fn serialise_cached_response(parts: &http::response::Parts, body: &Bytes) -> Vec<u8> {
298 let mut out: Vec<u8> = Vec::new();
299
300 let header_count = parts.headers.len() as u32;
301 out.extend_from_slice(&header_count.to_le_bytes());
302
303 for (name, value) in &parts.headers {
304 let name_bytes = name.as_str().as_bytes();
305 let value_bytes = value.as_bytes();
306 out.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
307 out.extend_from_slice(name_bytes);
308 out.extend_from_slice(&(value_bytes.len() as u32).to_le_bytes());
309 out.extend_from_slice(value_bytes);
310 }
311
312 out.extend_from_slice(body);
313 out
314}
315
316fn deserialise_cached_response(data: Vec<u8>) -> Result<Response<Body>, ()> {
317 if data.len() < 4 {
318 return Err(());
319 }
320 let mut pos = 0;
321
322 let header_count = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
323 pos += 4;
324
325 let mut builder = Response::builder().status(StatusCode::OK);
326
327 for _ in 0..header_count {
328 if pos + 4 > data.len() {
329 return Err(());
330 }
331 let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
332 pos += 4;
333 if pos + name_len > data.len() {
334 return Err(());
335 }
336 let name = std::str::from_utf8(&data[pos..pos + name_len]).map_err(|_| ())?;
337 pos += name_len;
338
339 if pos + 4 > data.len() {
340 return Err(());
341 }
342 let val_len = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
343 pos += 4;
344 if pos + val_len > data.len() {
345 return Err(());
346 }
347 let value = &data[pos..pos + val_len];
348 pos += val_len;
349
350 builder = builder.header(name, value);
351 }
352
353 let body_bytes = Bytes::copy_from_slice(&data[pos..]);
354 builder.body(Body::from(body_bytes)).map_err(|_| ())
355}