Make the origin's Cache-Control headers CDN-safe

An audit of the live site ahead of the Cloudflare move found three ways a
cache-everything rule would have gone wrong.

First, several routes named no policy at all — `/login`, `/issues.json`,
`/files/epub/*`, `/files/xtc/*`, `/robots.txt`, the error pages — so the edge
would have applied its own default TTL (Cloudflare: two hours on a 200). For
`/files/*` that means an authenticated download becoming a publicly cached one.
Those routes now say what they mean, and `security_headers` fails closed: a
response that set no `Cache-Control` gets `no-store`, so a route added later
cannot silently inherit the CDN's default. Everything cookie- or Basic-auth
gated (`/files/*`, `/opds*`) is `private, no-store` on every response, 401s and
404s included.

Second, the public pages said `max-age=300` alone. They now say
`public, max-age=300, s-maxage=86400`: five minutes for the browser, a day for
the edge, which is safe because publishing purges the edge. A request carrying
a `daily_session=` cookie still gets `private, no-store`.

Third, `/static/favicon.svg` and `/static/speculation.json` were referenced
without `?v=` while being served `immutable` for a year — editing either one
could never have reached a browser again. Both are now in the `ASSET_VERSION`
hash and referenced with the version, and `static_asset` only promises a year
when the URL actually carries `?v=`; a bare `/static/…` URL gets an hour and
revalidates against the same ETag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
2026-09-04 21:28:59 +00:00
co-authored by Claude Fable 5.1
parent 41fe61a691
commit 7d12f53314
5 changed files with 289 additions and 37 deletions
+17 -1
View File
@@ -1671,7 +1671,7 @@ mod tests {
assert_eq!(issue.status(), StatusCode::OK);
assert_eq!(
issue.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300"
"public, max-age=300, s-maxage=86400"
);
let html = String::from_utf8(
to_bytes(issue.into_body(), 1024 * 1024)
@@ -1703,6 +1703,10 @@ mod tests {
.await
.unwrap();
assert_eq!(archive.status(), StatusCode::OK);
assert_eq!(
archive.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300, s-maxage=86400"
);
let feed = app
.clone()
@@ -1718,6 +1722,10 @@ mod tests {
feed.headers().get(header::CONTENT_TYPE).unwrap(),
"application/atom+xml; charset=utf-8"
);
assert_eq!(
feed.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300, s-maxage=86400"
);
let feed = String::from_utf8(
to_bytes(feed.into_body(), 1024 * 1024)
.await
@@ -1750,6 +1758,10 @@ mod tests {
)
.await
.unwrap();
assert_eq!(
robots.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=86400"
);
let robots =
String::from_utf8(to_bytes(robots.into_body(), 4096).await.unwrap().to_vec()).unwrap();
assert!(robots.contains("Disallow: /dashboard"));
@@ -1763,6 +1775,10 @@ mod tests {
)
.await
.unwrap();
assert_eq!(
reports.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=300, s-maxage=86400"
);
let reports =
String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap();
assert!(reports.contains("\"status\": \"ok\""));