From f634f06bf1e5d4065983b55bfff91870d1dd8fb9 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Fri, 4 Sep 2026 22:04:07 +0000 Subject: [PATCH] Settle on a five-minute public cache and drop the purge rationale The edge-purge integration was reverted in the previous commit; this finishes the job. Public pages, the feed and /issues.json now say `public, max-age=300` for browsers and shared caches alike, the code comments and README explain why the longer s-maxage plus purge design was dropped, and the rollout runbook loses its purge step, marks the HTML cache rule optional, and warns to keep the record DNS-only until the Universal SSL certificate is active. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW --- README.md | 21 +++---- docs/runbooks/cdn-rollout.md | 107 ++++++++++++----------------------- src/server.rs | 2 +- src/web/issue.rs | 8 +-- src/web/mod.rs | 2 +- src/web/public.rs | 16 +++--- 6 files changed, 57 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 80c415d..dc8ee27 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,6 @@ daily-epub features prune # stale embeddings, old telemetry and assessment daily-epub backfill-social [--days 7] # re-poll social scores for recent articles daily-epub db migrate # run migrations (also automatic on every start) daily-epub config check # validate the config, print the resolved roles, keys and paths -daily-epub cdn purge # purge the CDN edge cache for the configured zone daily-epub users add USER [--admin] [--password-stdin] daily-epub users passwd USER [--password-stdin] daily-epub users role USER user|admin @@ -370,10 +369,6 @@ prints what resolved. | `server.login_window_minutes` | `15` | Length of the login throttle window. | | `server.jobs_enabled` | `true` | Allow the dashboard to start the fixed systemd job catalogue. | | `server.journal_lines` | `300` | Journal lines shown on a dashboard job page (10–5000). | -| `cdn.provider` | unset | `cloudflare`, or unset for no CDN integration. Setting it requires the zone id and the token. | -| `cdn.cloudflare_zone_id` | unset | Zone id from the Cloudflare dashboard overview. | -| `cdn.api_token` | — | **`DAILY_EPUB_CDN__API_TOKEN`**. Needs exactly one permission: `Zone → Cache Purge`, scoped to that zone. | -| `cdn.purge_after_publish` | `true` | Purge the whole edge cache after `generate` publishes. A failure is logged, never fatal; dry runs never purge. | `[curation.ranking]` holds the ranker's tunables. The learned signals are gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to @@ -591,7 +586,7 @@ respect it (runbook §2). The whole matrix: | route | `Cache-Control` | |---|---| -| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300, s-maxage=86400` | +| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300` | | the same pages with a `daily_session=` cookie | `private, no-store` | | `/robots.txt` | `public, max-age=86400` | | `/static/*?v=` | `public, max-age=31536000, immutable` | @@ -599,13 +594,13 @@ respect it (runbook §2). The whole matrix: | `/files/epub/*`, `/files/xtc/*`, `/opds*` (every status) | `private, no-store` | | `/dashboard*`, `/login`, `/account`, errors, anything else | `no-store` | -The five-minute browser age and the one-day edge age are the same header doing -two jobs: a reader's tab revalidates soon after a new issue lands, while the -edge is allowed to answer for a whole day because `generate` purges it the -moment it publishes (`[cdn]`, `daily-epub cdn purge`). The `no-store` on the -last row is a default applied by the `security_headers` middleware to any -response that set no policy of its own, so a route added later cannot silently -inherit the CDN's default TTL. +Five minutes is the whole freshness story: an issue changes once a day, a +reader's tab or a CDN edge revalidates within five minutes of the new one +landing, and signed-in requests never touch a shared cache at all. A longer +edge age with an API purge after each publish was considered and dropped as +not worth its moving parts. The `no-store` on the last row is a default applied +by the `security_headers` middleware to any response that set no policy of its +own, so a route added later cannot silently inherit the CDN's default TTL. ### Installing the XTC converter diff --git a/docs/runbooks/cdn-rollout.md b/docs/runbooks/cdn-rollout.md index fc5a8ad..b5bdbce 100644 --- a/docs/runbooks/cdn-rollout.md +++ b/docs/runbooks/cdn-rollout.md @@ -1,9 +1,9 @@ # Runbook — putting daily.hallada.net behind Cloudflare -**Written:** 2026-09-04 for the production host. Steps 1–2 happen in the Cloudflare -and registrar dashboards; steps 3–6 are on the server as an operator with `sudo`. -The origin-side changes (the `Cache-Control` matrix and `daily-epub cdn purge`) -ship in the same release, so deploy the new binary before step 5. +**Written:** 2026-09-04 for the production host. Steps 1–3 happen in the Cloudflare +and registrar dashboards; steps 4–5 are on the server as an operator with `sudo`. +The origin-side changes (the `Cache-Control` matrix below) ship in the same +release; deploy the new binary before enabling the proxy. Read the whole thing before starting step 1: a nameserver change is the one step that cannot be undone in seconds. @@ -14,7 +14,7 @@ The app decides what is cacheable; Cloudflare is configured only to obey it. | route | `Cache-Control` | |---|---| -| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300, s-maxage=86400` | +| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300` | | the same pages with a `daily_session=` cookie | `private, no-store` | | `/robots.txt` | `public, max-age=86400` | | `/static/*?v=` | `public, max-age=31536000, immutable` | @@ -24,10 +24,12 @@ The app decides what is cacheable; Cloudflare is configured only to obey it. Two facts follow from that table and matter for every choice below: -- The edge is allowed to hold a public page for a **day** (`s-maxage=86400`). - That is only correct because `generate` purges the whole zone right after it - publishes. If the purge is broken, the site serves yesterday's paper until the - day elapses. Step 5 is not optional. +- A public page is fresh for **five minutes** everywhere: browser and edge alike + revalidate within five minutes of a new issue landing, and nothing has to tell + the edge that the day changed. (A one-day edge age with an API purge after + every publish was tried and dropped: at this traffic an edge rarely keeps a + page long enough for the longer age to matter, and the purge was one more + token, config section and failure mode.) - Anything gated by a cookie or Basic auth already says `private, no-store`, so even a misconfigured cache-everything rule cannot make a download public. The cookie bypass rule in step 3 is defence in depth, not the only defence. @@ -61,7 +63,13 @@ host. Everything else in the zone must survive the move unchanged. 4. Change the nameservers at the registrar to the two Cloudflare assigns. Propagation is usually minutes and can be hours. Cloudflare emails when the - zone goes active. + zone goes active. **Keep `daily` DNS-only until the zone is Active *and* + SSL/TLS → Edge Certificates shows the Universal certificate as Active.** A + proxied host with no edge certificate yet fails every TLS handshake + (`SSL alert number 40`) while plain HTTP still redirects, which is exactly + what happened on the first attempt: readers whose resolvers had picked up + the new nameservers got a broken site. Set Full (strict) (step 2) before + flipping the record to Proxied. 5. Before the switch, drop the TTL on the `daily` record (60 s) so a rollback is quick. Verify afterwards: @@ -84,7 +92,7 @@ Under the zone's **SSL/TLS**, **Speed** and **Scrape Shield** sections: This one is easy to miss and quietly wrong: the default (**4 hours**) *raises* the `max-age` Cloudflare sends to browsers, so the deliberate 5-minute browser age on the public pages would become 4 hours and a reader's tab would show a - stale paper long after a purge. + stale paper for most of a morning. - **Speed → Optimization → Rocket Loader: OFF.** - **Scrape Shield → Email Address Obfuscation: OFF.** Both inject a Cloudflare-hosted script into the HTML. The site's CSP is @@ -111,7 +119,13 @@ The origin already sends `private, no-store` to a cookie-bearing request, but this makes the bypass a property of the request rather than of the response, so nothing is ever *looked up* in a shared cache for a signed-in reader. -**Rule 2 — "Cache by origin headers"** +**Rule 2 — "Cache by origin headers"** *(optional)* + +Without this rule Cloudflare caches only its default static extensions (the +CSS, JS, fonts and SVG here) and passes HTML straight through, which already +captures most of the benefit: TLS terminates at the edge and the origin is +reached over a warm connection. Add the rule if you want the HTML itself +served from the edge; it is only safe together with rule 1. - When incoming requests match: `http.host eq "daily.hallada.net"` - Then: **Eligible for cache** @@ -119,7 +133,7 @@ nothing is ever *looked up* in a shared cache for a signed-in reader. - **Browser TTL:** *Respect origin* That Edge TTL mode is the whole point of the origin work: a response with -`s-maxage` is cached for exactly that long, and a response with `no-store` (or +`max-age` is cached for exactly that long, and a response with `no-store` (or one that somehow arrives with no policy at all) is not cached. It is the reason the `security_headers` middleware defaults unknown routes to `no-store` — with this mode, "no header" means "do not cache" rather than "cache for 2 hours". @@ -166,60 +180,11 @@ Check a login attempt from a phone on cellular and one from the LAN land in different throttle buckets, and that `journalctl -u daily-epub` shows real client addresses rather than Cloudflare's. -## 5. Configure the purge +## 5. Verify -Create the API token in the Cloudflare dashboard: **My Profile → API Tokens → -Create Token → Custom token**, with the single permission **Zone → Cache Purge → -Purge**, **Zone Resources → Include → Specific zone → hallada.net**. Nothing -else — the app makes exactly one API call. Copy the token once; it is not shown -again. The zone id is on the zone's **Overview** page. - -In `/etc/daily-epub/config.toml`: - -```toml -[cdn] -provider = "cloudflare" -cloudflare_zone_id = "…" -purge_after_publish = true -``` - -and in the systemd environment file (the one the units already load, mode `0600`, -owned by `daily-epub`): - -``` -DAILY_EPUB_CDN__API_TOKEN=… -``` - -Setting `provider` without both the zone id and the token is a **config error**: -the app refuses to start rather than publishing into a stale edge. Confirm and -then purge by hand: - -```sh -sudo systemctl restart daily-epub -sudo -u daily-epub daily-epub --config /etc/daily-epub/config.toml config check | grep '^cdn' -# cdn: cloudflare · zone … · token present · purge_after_publish true -sudo -u daily-epub daily-epub --config /etc/daily-epub/config.toml cdn purge -# purged the whole cloudflare cache (id …) -``` - -`cdn purge` takes no run lock and touches neither the database nor the publish -directories, so it is safe to run at any time, including during a `generate`. - -From then on `generate` purges the whole zone after every successful publish. The -purge is *purge everything* on purpose: a new issue also changes the previous -issue's page (its "latest" nav marker moves), `/issues`, `/feed.xml` and -`/issues.json`, and a per-URL list of that set would rot. A purge failure is -logged at `warn` and never fails the run — so check for it after the first live -run: - -```sh -journalctl -u daily-epub-generate.service --since today | grep -i 'purge' -``` - -## 6. Verify - -Anonymous public page — expect `HIT` on the second request (the first fills the -edge) and the origin's own two-part `Cache-Control`: +Anonymous public page — with rule 2, expect `HIT` on the second request (the +first fills the edge); without it, `DYNAMIC`. Either way the origin's own +`Cache-Control` must come through unchanged: ```sh curl -sI https://daily.hallada.net/ | grep -iE 'cf-cache-status|cache-control|age' @@ -261,10 +226,9 @@ curl -sI https://daily.hallada.net/login | grep -iE 'cf-cache-status|cache-contr # cache-control: no-store ``` -Finally, the end-to-end check the whole exercise is about: run a `generate`, then -immediately `curl -sI https://daily.hallada.net/ | grep -i cf-cache-status` and -confirm it reads `MISS` (the purge emptied the edge) and that the page shows the -new issue. +Finally, the morning after: within five minutes of the `generate` timer firing, +`curl -s https://daily.hallada.net/ | grep -o 'issues/[0-9-]*' | head -1` shows +the new date from a network that is not signed in. ## Rollback @@ -274,8 +238,7 @@ new issue. - **Something worse:** set the `daily` DNS record back to **DNS only** (grey cloud). Traffic goes straight to the origin again within the record's TTL, and nothing about the origin's behaviour depends on Cloudflare being there — the - `Cache-Control` headers are correct without it, and `cdn.provider` can be - removed from the config at leisure. + `Cache-Control` headers are correct without it. - **Full retreat:** point the registrar's nameservers back at Route 53. The hosted zone still exists unless it was deleted; do not delete it until the Cloudflare setup has run for a few weeks. diff --git a/src/server.rs b/src/server.rs index 715a8a4..517836a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -304,7 +304,7 @@ async fn handle_issues_json(State(state): State) -> Response { }) .collect(); match serde_json::to_string_pretty(&issues) { - // Only publishing changes this, and publishing purges the edge (§3.12). + // Only publishing changes this; five minutes of staleness is fine (§3.12). Ok(body) => ( StatusCode::OK, [ diff --git a/src/web/issue.rs b/src/web/issue.rs index e37464e..1b67f2f 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -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, s-maxage=86400" + "public, max-age=300" ); let html = String::from_utf8( to_bytes(issue.into_body(), 1024 * 1024) @@ -1705,7 +1705,7 @@ mod tests { assert_eq!(archive.status(), StatusCode::OK); assert_eq!( archive.headers().get(header::CACHE_CONTROL).unwrap(), - "public, max-age=300, s-maxage=86400" + "public, max-age=300" ); let feed = app @@ -1724,7 +1724,7 @@ mod tests { ); assert_eq!( feed.headers().get(header::CACHE_CONTROL).unwrap(), - "public, max-age=300, s-maxage=86400" + "public, max-age=300" ); let feed = String::from_utf8( to_bytes(feed.into_body(), 1024 * 1024) @@ -1777,7 +1777,7 @@ mod tests { .unwrap(); assert_eq!( reports.headers().get(header::CACHE_CONTROL).unwrap(), - "public, max-age=300, s-maxage=86400" + "public, max-age=300" ); let reports = String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap(); diff --git a/src/web/mod.rs b/src/web/mod.rs index 8eb9fa7..f489493 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -771,7 +771,7 @@ mod tests { assert!(anonymous.headers().get(header::SET_COOKIE).is_none()); assert_eq!( anonymous.headers().get(header::CACHE_CONTROL).unwrap(), - "public, max-age=300, s-maxage=86400" + "public, max-age=300" ); assert_eq!( anonymous.headers().get("speculation-rules").unwrap(), diff --git a/src/web/public.rs b/src/web/public.rs index bb7cdf9..055a5d1 100644 --- a/src/web/public.rs +++ b/src/web/public.rs @@ -360,14 +360,14 @@ pub async fn robots() -> 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"; +/// Five minutes, for browsers and shared caches alike. An issue changes once a +/// day, so the only staleness this allows is the few minutes after a publish, +/// and the operator never sees even that: signed-in requests get +/// [`PRIVATE_CACHE`]. A longer shared-cache age was tried and rejected: it +/// needs a purge call after every publish to stay correct, and at this site's +/// traffic an edge rarely keeps a page long enough for the extra age to pay +/// for that machinery. +pub const PUBLIC_CACHE: &str = "public, max-age=300"; /// `Cache-Control` for anything a signed-in reader sees, and for every /// authenticated download. `private` keeps it out of shared caches even if a