//! Cloudflare edge-cache purge client. //! //! Deleting a quarantined object from origin stops origin serving, but //! Cloudflare caches `cdn.makenot.work/{key}` immutably for up to a year, so an //! already-edge-cached malicious object keeps serving from the edge until its //! TTL lapses (ultra-fuzz CDN scan-serve residual). Purging the specific URL //! closes that window immediately. //! //! Optional by design: when `CF_API_TOKEN` / `CF_ZONE_ID` are not configured, //! [`CloudflarePurger::from_env`] returns `None` and every purge becomes a //! logged no-op, the malware-quarantine WAM ticket remains the manual //! fallback, exactly the posture before this wiring existed. use crate::helpers::HTTP_CLIENT; /// Cloudflare's documented per-request file cap for `purge_cache` by URL. const PURGE_FILES_PER_REQUEST: usize = 30; /// Holds the credentials needed to call the Cloudflare cache-purge API for one /// zone. Cheap to clone (two owned strings); intended to live in shared context. #[derive(Clone)] pub struct CloudflarePurger { zone_id: String, api_token: String, } impl std::fmt::Debug for CloudflarePurger { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CloudflarePurger") .field("zone_id", &self.zone_id) .field("api_token", &"[REDACTED]") .finish() } } impl CloudflarePurger { /// Build from `CF_API_TOKEN` + `CF_ZONE_ID`. Returns `None` if either is /// unset or empty, in which case purges become logged no-ops. pub fn from_env() -> Option { let api_token = std::env::var("CF_API_TOKEN") .ok() .filter(|s| !s.is_empty())?; let zone_id = std::env::var("CF_ZONE_ID").ok().filter(|s| !s.is_empty())?; Some(Self { zone_id, api_token }) } /// Purge specific absolute URLs from Cloudflare's edge cache. /// /// Fire-and-forget at the call site (callers should not block enforcement on /// it); failures are logged, not propagated, the object is already gone from /// origin, so a failed edge purge degrades to the prior TTL-bounded residual /// rather than a serving failure. Batches to Cloudflare's per-request cap. pub async fn purge_urls(&self, urls: Vec) { for chunk in urls.chunks(PURGE_FILES_PER_REQUEST) { self.purge_chunk(chunk).await; } } async fn purge_chunk(&self, urls: &[String]) { if urls.is_empty() { return; } let endpoint = format!( "https://api.cloudflare.com/client/v4/zones/{}/purge_cache", self.zone_id ); let body = serde_json::json!({ "files": urls }); match HTTP_CLIENT .post(&endpoint) .bearer_auth(&self.api_token) .json(&body) .send() .await { Ok(resp) if resp.status().is_success() => { tracing::info!(count = urls.len(), "cloudflare edge-cache purge succeeded"); } Ok(resp) => { let status = resp.status(); let detail = resp.text().await.unwrap_or_default(); tracing::error!( %status, detail = %detail, count = urls.len(), "cloudflare edge-cache purge returned an error; quarantined URL may stay edge-cached until TTL" ); } Err(e) => tracing::error!( error = ?e, count = urls.len(), "cloudflare edge-cache purge request failed; quarantined URL may stay edge-cached until TTL" ), } } }