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:
thalladaandClaude Fable 5.1 committed 2026-09-04 21:28:59 +00:00
1 parent 41fe61a691
commit 7d12f53314
5 files changed
+289 -37

No files matched your search

+23 -4
View File
@@ -339,7 +339,7 @@ pub async fn feed(State(state): State<AppState>) -> Result<Response, WebError> {
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
(header::CACHE_CONTROL, PUBLIC_CACHE),
],
body,
)
@@ -349,21 +349,40 @@ pub async fn feed(State(state): State<AppState>) -> Result<Response, WebError> {
pub async fn robots() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
[
(header::CONTENT_TYPE, "text/plain; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
"User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n",
)
.into_response()
}
/// `Cache-Control` for an anonymous public page (§3.12).
///
/// The two ages are aimed at two different caches. `max-age=300` is the
/// browser's: a reader who leaves a tab open revalidates within five minutes of
/// a new issue landing. `s-maxage=86400` is the CDN's: an issue changes once a
/// day, so the edge should be allowed to answer for a day rather than asking
/// the origin every five minutes. That long edge life is only safe because
/// [`crate::cdn::purge_all`] runs right after a publish; without the purge the
/// edge would keep yesterday's paper for its whole day.
pub const PUBLIC_CACHE: &str = "public, max-age=300, s-maxage=86400";
/// `Cache-Control` for anything a signed-in reader sees, and for every
/// authenticated download. `private` keeps it out of shared caches even if a
/// CDN cache rule is misconfigured; `no-store` keeps it off disk.
pub const PRIVATE_CACHE: &str = "private, no-store";
fn public_cache(mut response: Response, request_headers: &HeaderMap) -> Response {
let value = if request_headers
.get(header::COOKIE)
.and_then(|value| value.to_str().ok())
.is_some_and(|cookies| cookies.contains("daily_session="))
{
"private, no-store"
PRIVATE_CACHE
} else {
"public, max-age=300"
PUBLIC_CACHE
};
response.headers_mut().insert(
header::CACHE_CONTROL,