Add a [cdn] section and purge the edge after publishing

The site is about to sit behind Cloudflare, where the public pages will be
allowed to live at the edge for a day (`s-maxage`, next commit). That is only
correct if the edge is emptied the moment a new issue lands, so the origin now
does the emptying itself rather than leaving it to an operator to remember.

`[cdn]` is inert by default: with no `provider` nothing is called and no token
is needed, so an origin with no CDN behaves exactly as before. Setting
`provider = "cloudflare"` without both a zone id and `DAILY_EPUB_CDN__API_TOKEN`
is a config error — a half-configured purge would publish into a stale edge and
say nothing.

The purge is `purge_everything` on purpose. A new issue changes more than its
own page: `/`, `/issues`, `/feed.xml`, `/issues.json`, and the previous issue's
page too, whose "latest" nav marker moves. A per-URL list of that set is exactly
the kind of thing that silently rots, and everything expensive at the edge is
content-hashed, so refilling it costs one origin fetch.

A purge failure is logged at warn and never fails the run: the paper is already
published and recorded by then, and a few stale hours are not worth failing over.
`daily-epub cdn purge` runs the same code by hand; it takes no run lock because
it touches neither the database nor the publish directories.

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:48 +00:00
co-authored by Claude Fable 5.1
parent 2388d7bd02
commit 41fe61a691
7 changed files with 580 additions and 1 deletions
+141
View File
@@ -82,6 +82,8 @@ pub struct Config {
pub publish: PublishConfig,
pub xtc: XtcConfig,
pub server: ServerConfig,
/// `[cdn]` — the CDN in front of the origin, and the post-publish purge.
pub cdn: CdnConfig,
}
impl Default for Config {
@@ -106,6 +108,7 @@ impl Default for Config {
publish: PublishConfig::default(),
xtc: XtcConfig::default(),
server: ServerConfig::default(),
cdn: CdnConfig::default(),
}
}
}
@@ -728,6 +731,111 @@ impl Default for ServerConfig {
}
}
/// The only CDN provider the purge knows how to talk to.
pub const CDN_CLOUDFLARE: &str = "cloudflare";
/// `[cdn]` — the CDN in front of the origin (§3.12).
///
/// Everything here is off by default: with no `provider` the app behaves
/// exactly as it did before a CDN existed. The one thing the origin does when
/// a provider *is* configured is purge the edge right after a successful
/// publish, so the new issue (and the previous issue's "latest" nav marker)
/// stop being served stale for the `s-maxage` day.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct CdnConfig {
/// `"cloudflare"`, or unset to disable the purge entirely.
pub provider: Option<String>,
/// The zone the site lives in, from the Cloudflare dashboard's overview.
pub cloudflare_zone_id: Option<String>,
/// Supply only via `DAILY_EPUB_CDN__API_TOKEN`; never put it in the TOML.
///
/// The token needs a single permission — `Zone → Cache Purge` — scoped to
/// the one zone.
pub api_token: Option<String>,
/// Purge the edge after `generate` publishes an issue (dry runs never do).
pub purge_after_publish: bool,
}
impl Default for CdnConfig {
fn default() -> Self {
Self {
provider: None,
cloudflare_zone_id: None,
api_token: None,
purge_after_publish: true,
}
}
}
impl CdnConfig {
/// The only place the token may come from.
pub fn api_token_env_var() -> String {
format!("{ENV_PREFIX}CDN{ENV_SPLIT}API_TOKEN")
}
/// The provider name, trimmed and lowercased, when one is configured.
pub fn provider_name(&self) -> Option<String> {
self.provider
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_lowercase)
}
/// The zone id, trimmed, when one is configured.
pub fn zone_id(&self) -> Option<&str> {
self.cloudflare_zone_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
}
/// The token, trimmed, when one is configured.
pub fn api_token(&self) -> Option<&str> {
self.api_token
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
}
/// True when a recognised provider is configured.
pub fn is_enabled(&self) -> bool {
self.provider_name().is_some()
}
/// A copy safe to log or persist: the token is stripped.
pub fn redacted(&self) -> Self {
Self {
api_token: None,
..self.clone()
}
}
fn validate(&self) -> Result<(), ConfigError> {
let Some(provider) = self.provider_name() else {
return Ok(());
};
if provider != CDN_CLOUDFLARE {
return Err(ConfigError::Invalid(format!(
"cdn.provider {provider:?} is not recognised; the only supported value is \"{CDN_CLOUDFLARE}\""
)));
}
if self.zone_id().is_none() {
return Err(ConfigError::Invalid(
"cdn.provider = \"cloudflare\" requires cdn.cloudflare_zone_id".into(),
));
}
if self.api_token().is_none() {
return Err(ConfigError::Invalid(format!(
"cdn.provider = \"cloudflare\" requires an API token; set {}",
Self::api_token_env_var()
)));
}
Ok(())
}
}
/// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
/// a stale-configuration error.
const LLM_ROLE_KEYS: &[&str] = &[
@@ -940,6 +1048,19 @@ impl Config {
));
lines.push(file_line("publish.epub_dir", &self.publish.epub_dir));
lines.push(file_line("publish.xtc_dir", &self.publish.xtc_dir));
lines.push(match self.cdn.provider_name() {
None => "cdn: disabled (no cdn.provider)".to_string(),
Some(provider) => format!(
"cdn: {provider} · zone {} · {} · purge_after_publish {}",
self.cdn.zone_id().unwrap_or("MISSING"),
if self.cdn.api_token().is_some() {
"token present".to_string()
} else {
format!("token MISSING (set {})", CdnConfig::api_token_env_var())
},
self.cdn.purge_after_publish,
),
});
lines
}
@@ -1072,6 +1193,7 @@ impl Config {
"curation.sections must not be empty".into(),
));
}
self.cdn.validate()?;
self.tz()?;
Ok(())
}
@@ -1291,6 +1413,10 @@ mod tests {
command = "node"
args = ["/opt/epub-to-xtc-converter/cli/index.js", "convert"]
format = "xtc"
[cdn]
provider = "cloudflare"
cloudflare_zone_id = "zone-abc"
"#,
)?;
jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token");
@@ -1300,6 +1426,7 @@ mod tests {
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key");
jail.set_env("DAILY_EPUB_LLM__EDITOR", "gemini");
jail.set_env("DAILY_EPUB_CDN__API_TOKEN", "cdn-token");
let c = Config::load(None).map_err(|e| figment::Error::from(e.to_string()))?;
assert_eq!(c.voyage.api_key.as_deref(), Some("voyage-key"));
@@ -1324,6 +1451,20 @@ mod tests {
assert_eq!(c.miniflux.api_key.as_deref(), Some("secret-token"));
assert_eq!(c.target_article_count, 12);
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
// The CDN token arrives only through the environment, and the whole
// section validates as a unit (provider ⇒ zone id ⇒ token).
assert_eq!(c.cdn.api_token(), Some("cdn-token"));
assert_eq!(c.cdn.zone_id(), Some("zone-abc"));
assert!(c.cdn.purge_after_publish);
assert!(
c.check_report(None)
.iter()
.any(|line| line.starts_with("cdn: cloudflare ")
&& line.contains("token present")
&& !line.contains("cdn-token")),
"{:?}",
c.check_report(None)
);
// untouched default
assert_eq!(c.retention_days, 21);
assert_eq!(c.timezone, "America/New_York");