From de332cd4b3e219d554faaa71e35b0af1fcdc3c3f Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Fri, 4 Sep 2026 22:00:06 +0000 Subject: [PATCH] Revert "Add a [cdn] section and purge the edge after publishing" This reverts commit 41fe61a691d48884b15d432f2ae8b8fc448c1223. --- config.example.toml | 16 -- src/cdn.rs | 368 ---------------------------------- src/config.rs | 141 ------------- src/lib.rs | 1 - src/main.rs | 22 -- src/pipeline.rs | 22 -- src/web/dashboard/settings.rs | 11 +- 7 files changed, 1 insertion(+), 580 deletions(-) delete mode 100644 src/cdn.rs diff --git a/config.example.toml b/config.example.toml index 3d2945c..e9c372f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -220,19 +220,3 @@ journal_lines = 300 # job-page journal tail; valid range 10..=5 # routes remain public. A signed-in web user can download without Basic auth. # basic_auth_user = "daily" # basic_auth_pass = "..." - -# [cdn] -# The CDN in front of the origin. Leave the whole table out (the default) and -# nothing here applies: no purge is attempted and no token is needed. -# -# provider = "cloudflare" # the only recognised value today -# cloudflare_zone_id = "..." # zone id from the Cloudflare dashboard overview -# purge_after_publish = true # purge the edge after `generate` publishes (dry runs never do) -# -# api_token via DAILY_EPUB_CDN__API_TOKEN env; never put it in this file. -# The token needs exactly one permission — Zone -> Cache Purge — scoped to this -# one zone. Nothing else is called, so nothing else should be granted. -# -# Setting `provider` without both the zone id and the token is a config error. -# See docs/runbooks/cdn-rollout.md for the zone settings and cache rules that -# make the origin's Cache-Control headers actually apply at the edge. diff --git a/src/cdn.rs b/src/cdn.rs deleted file mode 100644 index 9305e07..0000000 --- a/src/cdn.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! CDN cache purge (spec §3.12). -//! -//! The origin sends `s-maxage=86400` on the public pages and the feed, so the -//! edge holds a copy for a day. That is only safe because publishing a new -//! issue purges the edge immediately afterwards. -//! -//! The purge is deliberately a *purge everything*, not a list of URLs: a new -//! issue changes more than its own page. `/` becomes the new issue, `/issues` -//! grows a row, `/feed.xml` and `/issues.json` change, and the *previous* -//! issue's page changes too — its "latest" nav marker moves. Enumerating that -//! set correctly is exactly the kind of thing that silently rots. Everything -//! expensive at the edge (`/static/*`) is content-hashed and carries a -//! versioned URL, so re-fetching it after a purge costs one origin hit. - -use std::time::Duration; - -use crate::config::{CDN_CLOUDFLARE, Config}; -use crate::http::{RetryPolicy, build_client}; - -/// Cloudflare's API root; overridden in tests to point at a loopback server. -pub const CLOUDFLARE_API_BASE: &str = "https://api.cloudflare.com/client/v4"; - -/// A purge is a single small request; do not let it hold up a finished run. -const PURGE_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Debug, thiserror::Error)] -pub enum CdnError { - #[error("cdn.provider {0:?} is not recognised")] - UnknownProvider(String), - #[error("cdn.cloudflare_zone_id is not set")] - MissingZoneId, - #[error("no CDN API token; set {0}")] - MissingToken(String), - #[error("building the HTTP client failed: {0}")] - Client(#[source] reqwest::Error), - #[error("the cache purge request failed: {0}")] - Request(#[source] reqwest::Error), - #[error("the cache purge was rejected (HTTP {status}): {message}")] - Api { status: u16, message: String }, -} - -/// What [`purge_all`] did. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PurgeOutcome { - /// No `cdn.provider` is configured; nothing was called. - Disabled, - /// The provider accepted the purge. - Purged { - provider: &'static str, - /// The provider's own id for the purge, when it returns one. - id: Option, - }, -} - -impl std::fmt::Display for PurgeOutcome { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PurgeOutcome::Disabled => write!(f, "cdn purge disabled (no cdn.provider configured)"), - PurgeOutcome::Purged { provider, id } => match id { - Some(id) => write!(f, "purged the whole {provider} cache (id {id})"), - None => write!(f, "purged the whole {provider} cache"), - }, - } - } -} - -/// Purge the CDN's entire cache for the configured zone. -/// -/// Returns [`PurgeOutcome::Disabled`] without touching the network when no -/// provider is configured, so callers need no `if enabled` of their own. -pub async fn purge_all(cfg: &Config) -> Result { - purge_all_at(cfg, CLOUDFLARE_API_BASE).await -} - -/// [`purge_all`] against an explicit API root (tests point this at loopback). -pub async fn purge_all_at(cfg: &Config, api_base: &str) -> Result { - let Some(provider) = cfg.cdn.provider_name() else { - return Ok(PurgeOutcome::Disabled); - }; - if provider != CDN_CLOUDFLARE { - return Err(CdnError::UnknownProvider(provider)); - } - let zone = cfg.cdn.zone_id().ok_or(CdnError::MissingZoneId)?; - let token = cfg - .cdn - .api_token() - .ok_or_else(|| CdnError::MissingToken(crate::config::CdnConfig::api_token_env_var()))?; - - let client = build_client(PURGE_TIMEOUT).map_err(CdnError::Client)?; - let url = format!( - "{}/zones/{zone}/purge_cache", - api_base.trim_end_matches('/') - ); - let policy = RetryPolicy { - max_attempts: 3, - base_delay: Duration::from_millis(500), - max_delay: Duration::from_secs(5), - }; - let id = policy - .run("cloudflare cache purge", is_retryable, || async { - let response = client - .post(&url) - .bearer_auth(token) - .json(&serde_json::json!({ "purge_everything": true })) - .send() - .await - .map_err(CdnError::Request)?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - interpret(status.as_u16(), &body) - }) - .await?; - Ok(PurgeOutcome::Purged { - provider: CDN_CLOUDFLARE, - id, - }) -} - -/// Cloudflare answers `{"success": …, "errors": [{"code", "message"}], "result": …}`. -/// A 200 with `success: false` is still a failure, so the status alone is not -/// enough to go on. -fn interpret(status: u16, body: &str) -> Result, CdnError> { - let json: Option = serde_json::from_str(body).ok(); - let success = json - .as_ref() - .and_then(|value| value.get("success")) - .and_then(serde_json::Value::as_bool); - if (200..300).contains(&status) && success != Some(false) { - let id = json - .as_ref() - .and_then(|value| value.pointer("/result/id")) - .and_then(serde_json::Value::as_str) - .map(str::to_string); - return Ok(id); - } - Err(CdnError::Api { - status, - message: api_message(json.as_ref(), body), - }) -} - -/// The API's own `errors[].message` text, falling back to the raw body. -fn api_message(json: Option<&serde_json::Value>, body: &str) -> String { - let messages: Vec = json - .and_then(|value| value.get("errors")) - .and_then(serde_json::Value::as_array) - .map(|errors| { - errors - .iter() - .filter_map(|error| { - let message = error.get("message").and_then(serde_json::Value::as_str)?; - Some( - match error.get("code").and_then(serde_json::Value::as_i64) { - Some(code) => format!("{message} (code {code})"), - None => message.to_string(), - }, - ) - }) - .collect() - }) - .unwrap_or_default(); - if !messages.is_empty() { - return messages.join("; "); - } - let trimmed = body.trim(); - if trimmed.is_empty() { - "no response body".into() - } else { - trimmed.chars().take(300).collect() - } -} - -/// Retry transport failures and 5xx/429; a 4xx (bad token, wrong zone) is the -/// operator's problem and retrying only delays the message. -fn is_retryable(error: &CdnError) -> bool { - match error { - CdnError::Request(source) => crate::http::is_retryable(source), - CdnError::Api { status, .. } => *status >= 500 || *status == 429, - _ => false, - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use axum::Router; - use axum::extract::State; - use axum::http::{HeaderMap, StatusCode}; - use axum::routing::post; - use serde_json::json; - - use super::*; - - #[derive(Clone, Default)] - struct Fake { - scripted: Arc>>, - seen: Arc>>, - } - - impl Fake { - fn push(&self, status: StatusCode, body: serde_json::Value) { - self.scripted - .lock() - .expect("script lock") - .push_back((status, body)); - } - - fn requests(&self) -> Vec<(HeaderMap, serde_json::Value)> { - self.seen.lock().expect("seen lock").clone() - } - } - - async fn handle( - State(fake): State, - headers: HeaderMap, - axum::Json(body): axum::Json, - ) -> (StatusCode, axum::Json) { - fake.seen.lock().expect("seen lock").push((headers, body)); - let (status, body) = fake - .scripted - .lock() - .expect("script lock") - .pop_front() - .unwrap_or(( - StatusCode::INTERNAL_SERVER_ERROR, - json!({"success": false, "errors": [{"message": "unscripted"}]}), - )); - (status, axum::Json(body)) - } - - async fn serve(fake: Fake) -> String { - let app = Router::new() - .route("/zones/{zone}/purge_cache", post(handle)) - .with_state(fake); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("loopback listener"); - let addr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - format!("http://{addr}") - } - - fn configured() -> Config { - let mut config = Config::default(); - config.cdn.provider = Some("cloudflare".into()); - config.cdn.cloudflare_zone_id = Some("zone-abc".into()); - config.cdn.api_token = Some("token-never-logged".into()); - config - } - - #[tokio::test] - async fn no_provider_means_no_request() { - let config = Config::default(); - assert!(!config.cdn.is_enabled()); - assert_eq!( - purge_all_at(&config, "http://127.0.0.1:1/never-called") - .await - .unwrap(), - PurgeOutcome::Disabled - ); - } - - #[tokio::test] - async fn a_successful_purge_sends_the_documented_request() { - let fake = Fake::default(); - fake.push( - StatusCode::OK, - json!({"success": true, "errors": [], "messages": [], "result": {"id": "zone-abc"}}), - ); - let base = serve(fake.clone()).await; - - let outcome = purge_all_at(&configured(), &base).await.expect("purge"); - assert_eq!( - outcome, - PurgeOutcome::Purged { - provider: "cloudflare", - id: Some("zone-abc".into()), - } - ); - - let requests = fake.requests(); - assert_eq!(requests.len(), 1); - let (headers, body) = &requests[0]; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer token-never-logged") - ); - assert_eq!(body, &json!({"purge_everything": true})); - } - - #[tokio::test] - async fn a_success_false_body_is_an_error_carrying_the_api_message() { - let fake = Fake::default(); - fake.push( - StatusCode::OK, - json!({ - "success": false, - "errors": [{"code": 10000, "message": "Authentication error"}], - "result": null - }), - ); - let base = serve(fake).await; - let error = purge_all_at(&configured(), &base).await.unwrap_err(); - let text = error.to_string(); - assert!(text.contains("Authentication error"), "{text}"); - assert!(text.contains("10000"), "{text}"); - } - - #[tokio::test] - async fn a_4xx_is_not_retried_and_a_5xx_is() { - let fake = Fake::default(); - fake.push( - StatusCode::FORBIDDEN, - json!({"success": false, "errors": [{"code": 9109, "message": "Invalid access token"}]}), - ); - let base = serve(fake.clone()).await; - let error = purge_all_at(&configured(), &base).await.unwrap_err(); - assert!(error.to_string().contains("Invalid access token")); - assert_eq!(fake.requests().len(), 1, "a 403 must not be retried"); - - let fake = Fake::default(); - fake.push( - StatusCode::BAD_GATEWAY, - json!({"success": false, "errors": [{"message": "bad gateway"}]}), - ); - fake.push( - StatusCode::OK, - json!({"success": true, "errors": [], "result": {"id": "zone-abc"}}), - ); - let base = serve(fake.clone()).await; - purge_all_at(&configured(), &base) - .await - .expect("the retry succeeds"); - assert_eq!(fake.requests().len(), 2); - } - - #[test] - fn config_validation_demands_a_zone_and_a_token() { - let mut config = Config::default(); - config.validate().expect("no cdn section is valid"); - - config.cdn.provider = Some("cloudflare".into()); - let error = config.validate().unwrap_err().to_string(); - assert!(error.contains("cloudflare_zone_id"), "{error}"); - - config.cdn.cloudflare_zone_id = Some("zone-abc".into()); - let error = config.validate().unwrap_err().to_string(); - assert!(error.contains("DAILY_EPUB_CDN__API_TOKEN"), "{error}"); - - config.cdn.api_token = Some("token".into()); - config.validate().expect("fully configured"); - - config.cdn.provider = Some("fastly".into()); - let error = config.validate().unwrap_err().to_string(); - assert!(error.contains("not recognised"), "{error}"); - } - - #[test] - fn the_token_is_stripped_from_a_redacted_copy() { - let config = configured(); - let redacted = config.cdn.redacted(); - assert!(redacted.api_token.is_none()); - assert_eq!(redacted.cloudflare_zone_id.as_deref(), Some("zone-abc")); - } -} diff --git a/src/config.rs b/src/config.rs index 2c20dd4..63a1f54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -82,8 +82,6 @@ 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 { @@ -108,7 +106,6 @@ impl Default for Config { publish: PublishConfig::default(), xtc: XtcConfig::default(), server: ServerConfig::default(), - cdn: CdnConfig::default(), } } } @@ -731,111 +728,6 @@ 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, - /// The zone the site lives in, from the Cloudflare dashboard's overview. - pub cloudflare_zone_id: Option, - /// 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, - /// 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 { - 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] = &[ @@ -1048,19 +940,6 @@ 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 } @@ -1193,7 +1072,6 @@ impl Config { "curation.sections must not be empty".into(), )); } - self.cdn.validate()?; self.tz()?; Ok(()) } @@ -1413,10 +1291,6 @@ 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"); @@ -1426,7 +1300,6 @@ 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")); @@ -1451,20 +1324,6 @@ 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"); diff --git a/src/lib.rs b/src/lib.rs index 9aa3678..915b00a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,6 @@ //! ``` pub mod auth; -pub mod cdn; pub mod comments; pub mod config; pub mod curate; diff --git a/src/main.rs b/src/main.rs index 21704e7..551d6ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,15 +64,6 @@ enum Command { /// Operator jobs (what `daily-epub-job@.service` runs). #[command(subcommand)] Job(JobCommand), - /// CDN cache maintenance. - #[command(subcommand)] - Cdn(CdnCommand), -} - -#[derive(Debug, Subcommand)] -enum CdnCommand { - /// Purge the whole edge cache for the configured zone. - Purge, } #[derive(Debug, Subcommand)] @@ -378,17 +369,6 @@ async fn main() -> Result<()> { db.migrate().await?; println!("migrations up to date: {}", config.database_path.display()); } - Command::Cdn(CdnCommand::Purge) => { - // No database, no files, no provider budgets: nothing the run lock - // protects, so `generate` and a purge may safely overlap. - match daily_epub::cdn::purge_all(&config).await { - Ok(outcome) => println!("{outcome}"), - Err(error) => { - eprintln!("cdn purge failed: {error}"); - std::process::exit(1); - } - } - } Command::Config(ConfigCommand::Check) => { // Reaching here means `Config::load` already validated it; a bad // config exited non-zero above. Nothing is opened, nothing locked. @@ -446,7 +426,6 @@ fn lock_holder(command: &Command) -> Option<&'static str> { | Command::Features(FeaturesCommand::Prune) | Command::Db(_) | Command::Config(_) - | Command::Cdn(_) | Command::Users(_) => None, } } @@ -1122,7 +1101,6 @@ mod tests { vec!["db", "migrate"], vec!["features", "prune"], vec!["config", "check"], - vec!["cdn", "purge"], vec!["job", "run", "features-prune"], vec!["job", "run", "not-a-job"], ] { diff --git a/src/pipeline.rs b/src/pipeline.rs index 4fcb9d7..55e2f5c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -786,11 +786,6 @@ async fn run_stages( record_issue(db, &issue, &published) .await .context("recording the issue")?; - // The edge holds the public pages for `s-maxage=86400`, so it has to be - // told the day changed. Best effort: the paper is already published and - // recorded, and a stale edge for a few hours is not worth failing a run - // that otherwise succeeded (§3.12). - purge_cdn(config).await; Some(published) }; report.timings.record("publish", elapsed_ms(stage)); @@ -803,23 +798,6 @@ async fn run_stages( }) } -/// Purge the CDN after a successful publish, never failing the run. -async fn purge_cdn(config: &Config) { - if !config.cdn.purge_after_publish { - tracing::debug!("cdn purge skipped: cdn.purge_after_publish is false"); - return; - } - match crate::cdn::purge_all(config).await { - Ok(crate::cdn::PurgeOutcome::Disabled) => { - tracing::debug!("cdn purge skipped: no cdn.provider configured"); - } - Ok(outcome) => tracing::info!("{outcome}"), - Err(error) => { - tracing::warn!(%error, "the CDN cache purge failed; the edge may serve the previous issue until its s-maxage expires") - } - } -} - /// Near misses listed in the "Behind the paper" chapter (§15.1). const NEAR_MISSES_IN_PAPER: usize = 10; diff --git a/src/web/dashboard/settings.rs b/src/web/dashboard/settings.rs index 9b21a12..8a316f2 100644 --- a/src/web/dashboard/settings.rs +++ b/src/web/dashboard/settings.rs @@ -218,12 +218,9 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[ ("server.basic_auth_user", FieldKind::Text), ("server.basic_auth_pass", FieldKind::Secret), ("xtc.settings", FieldKind::Path), - ("cdn.provider", FieldKind::Text), - ("cdn.cloudflare_zone_id", FieldKind::Text), - ("cdn.api_token", FieldKind::Secret), ]; -const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass", "api_token"]; +const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass"]; const PATH_KEYS: &[&str] = &[ "database_path", @@ -258,7 +255,6 @@ const GROUP_ORDER: &[&str] = &[ "publish", "xtc", "server", - "cdn", "miniflux", ]; @@ -371,10 +367,6 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[ ("server.login_window_minutes", "Length of the login throttle window."), ("server.jobs_enabled", "Allow the dashboard to start the fixed systemd job catalogue."), ("server.journal_lines", "Journal lines shown on a dashboard job page (10-5000)."), - ("cdn.provider", "cloudflare, or empty for no CDN integration at all. Setting it requires both cdn.cloudflare_zone_id and the API token."), - ("cdn.cloudflare_zone_id", "Zone id from the Cloudflare dashboard overview for the site's zone."), - ("cdn.api_token", "Environment only (DAILY_EPUB_CDN__API_TOKEN). Needs exactly one permission: Zone -> Cache Purge, scoped to that one zone."), - ("cdn.purge_after_publish", "Purge the whole edge cache after generate publishes an issue. A purge failure is logged and does not fail the run; dry runs never purge."), ]; /// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2). @@ -1691,7 +1683,6 @@ mod tests { "publish", "xtc", "server", - "cdn", "miniflux", ] );