1use crate::Error;
26use async_trait::async_trait;
27use std::collections::VecDeque;
28use std::time::Duration;
29use tokio::sync::Mutex;
30use tokio::time::Instant;
31
32const DO_CDN_API_BASE: &str = "https://api.digitalocean.com";
33const BATCH_SIZE: usize = 50;
34const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(10);
35const RATE_LIMIT_MAX: usize = 5;
36
37#[async_trait]
42pub trait PurgeApi: Send + Sync {
43 async fn purge(&self, paths: &[String]) -> Result<(), Error>;
48}
49
50#[derive(Clone)]
58pub struct DoSpacesCdnConfig {
59 pub endpoint_id: Option<String>,
61 pub api_token: String,
63 pub(crate) api_base: Option<String>,
65}
66
67impl std::fmt::Debug for DoSpacesCdnConfig {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("DoSpacesCdnConfig")
70 .field("endpoint_id", &self.endpoint_id)
71 .field("api_token", &"<redacted>")
72 .field("api_base", &self.api_base)
73 .finish()
74 }
75}
76
77impl DoSpacesCdnConfig {
78 pub fn from_env() -> Self {
83 Self {
84 endpoint_id: std::env::var("DO_SPACES_CDN_ID").ok(),
85 api_token: std::env::var("DIGITALOCEAN_ACCESS_TOKEN").unwrap_or_default(),
86 api_base: None,
87 }
88 }
89}
90
91pub struct DoSpacesCdn {
96 config: DoSpacesCdnConfig,
97 client: reqwest::Client,
98 request_times: Mutex<VecDeque<Instant>>,
99}
100
101impl DoSpacesCdn {
102 pub fn new(config: DoSpacesCdnConfig) -> Self {
104 Self {
105 config,
106 client: reqwest::Client::new(),
107 request_times: Mutex::new(VecDeque::new()),
108 }
109 }
110
111 fn api_base(&self) -> &str {
112 self.config.api_base.as_deref().unwrap_or(DO_CDN_API_BASE)
113 }
114
115 async fn throttle(&self) {
121 loop {
122 let mut times = self.request_times.lock().await;
123 let now = Instant::now();
124 while times
126 .front()
127 .map(|t| now.duration_since(*t) >= RATE_LIMIT_WINDOW)
128 .unwrap_or(false)
129 {
130 times.pop_front();
131 }
132 if times.len() < RATE_LIMIT_MAX {
133 times.push_back(Instant::now());
135 return;
136 }
137 let oldest = *times.front().unwrap();
139 let sleep_for = RATE_LIMIT_WINDOW - now.duration_since(oldest);
140 drop(times);
141 tokio::time::sleep(sleep_for).await;
142 }
144 }
145}
146
147#[async_trait]
148impl PurgeApi for DoSpacesCdn {
149 async fn purge(&self, paths: &[String]) -> Result<(), Error> {
150 if paths.is_empty() {
151 return Ok(());
152 }
153 let Some(id) = &self.config.endpoint_id else {
154 tracing::info!("DO_SPACES_CDN_ID not set — CDN purge is a no-op");
155 return Ok(());
156 };
157 if self.config.api_token.is_empty() {
158 return Err(Error::cdn(
159 "DIGITALOCEAN_ACCESS_TOKEN not set — cannot purge CDN cache",
160 ));
161 }
162 let url = format!("{}/v2/cdn/endpoints/{}/cache", self.api_base(), id);
163 let mut batches = 0usize;
164 for chunk in paths.chunks(BATCH_SIZE) {
165 self.throttle().await;
166 let resp = self
167 .client
168 .delete(&url)
169 .bearer_auth(&self.config.api_token)
170 .json(&serde_json::json!({ "files": chunk }))
171 .send()
172 .await
173 .map_err(|e| Error::cdn(e.to_string()))?;
174 if resp.status().as_u16() != 204 {
175 let status = resp.status().as_u16();
176 let body = resp.text().await.unwrap_or_default();
177 return Err(Error::cdn(format!("DO CDN purge status {status}: {body}")));
178 }
179 batches += 1;
180 }
181 tracing::info!("purged {} paths in {} request(s)", paths.len(), batches);
182 Ok(())
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Default)]
188pub enum CdnProvider {
189 #[default]
191 None,
192 DigitalOcean,
194 Bunny,
196 Cloudflare,
198}
199
200impl CdnProvider {
201 pub fn from_str_ci(s: &str) -> Result<Self, Error> {
203 match s.trim().to_ascii_lowercase().as_str() {
204 "none" => Ok(Self::None),
205 "digitalocean" => Ok(Self::DigitalOcean),
206 "bunny" => Ok(Self::Bunny),
207 "cloudflare" => Ok(Self::Cloudflare),
208 other => Err(Error::cdn_invalid_provider(other)),
209 }
210 }
211}
212
213fn env_with_fallback(primary: &str, aliases: &[&str]) -> Option<String> {
216 if let Ok(val) = std::env::var(primary) {
217 return Some(val);
218 }
219 for alias in aliases {
220 if let Ok(val) = std::env::var(alias) {
221 tracing::warn!("{alias} is deprecated; use {primary} instead");
222 return Some(val);
223 }
224 }
225 None
226}
227
228#[derive(Clone, Default)]
234pub struct Config {
235 pub url: Option<String>,
237 pub provider: CdnProvider,
239 pub purge_token: Option<String>,
241 pub purge_zone: Option<String>,
243 pub provider_error: Option<String>,
246}
247
248impl std::fmt::Debug for Config {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 f.debug_struct("cdn::Config")
251 .field("url", &self.url)
252 .field("provider", &self.provider)
253 .field("purge_token", &"<redacted>")
254 .field("purge_zone", &self.purge_zone)
255 .field("provider_error", &self.provider_error)
256 .finish()
257 }
258}
259
260impl Config {
261 pub fn from_env() -> Self {
271 let url = env_with_fallback("CDN_URL", &["AWS_CDN_URL", "CF_CDN_URL", "BUNNY_CDN_URL"]);
273
274 let mut provider_error: Option<String> = None;
276 let provider = if let Ok(val) = std::env::var("CDN_PROVIDER") {
277 match CdnProvider::from_str_ci(&val) {
278 Ok(p) => p,
279 Err(e) => {
280 tracing::error!("{e}; set CDN_PROVIDER to a valid value to enable purging");
281 provider_error = Some(val);
282 CdnProvider::None
283 }
284 }
285 } else if std::env::var("DO_SPACES_CDN_ID").is_ok() {
286 tracing::warn!("CDN_PROVIDER unset; inferred digitalocean from DO_SPACES_CDN_ID. Set CDN_PROVIDER=digitalocean to silence.");
287 CdnProvider::DigitalOcean
288 } else if std::env::var("CF_ZONE_ID").is_ok() {
289 tracing::warn!("CDN_PROVIDER unset; inferred cloudflare from CF_ZONE_ID. Set CDN_PROVIDER=cloudflare to silence.");
290 CdnProvider::Cloudflare
291 } else if std::env::var("BUNNY_CDN_URL").is_ok()
292 || std::env::var("BUNNY_ACCESS_KEY").is_ok()
293 {
294 tracing::warn!("CDN_PROVIDER unset; inferred bunny from BUNNY_CDN_URL/BUNNY_ACCESS_KEY. Set CDN_PROVIDER=bunny to silence.");
295 CdnProvider::Bunny
296 } else {
297 CdnProvider::None
298 };
299
300 let (purge_token, purge_zone) = match &provider {
303 CdnProvider::DigitalOcean => (
304 env_with_fallback("CDN_PURGE_TOKEN", &["DIGITALOCEAN_ACCESS_TOKEN"]),
305 env_with_fallback("CDN_PURGE_ZONE", &["DO_SPACES_CDN_ID"]),
306 ),
307 CdnProvider::Cloudflare => (
308 env_with_fallback("CDN_PURGE_TOKEN", &["CF_API_TOKEN"]),
309 env_with_fallback("CDN_PURGE_ZONE", &["CF_ZONE_ID"]),
310 ),
311 CdnProvider::Bunny => (
312 env_with_fallback("CDN_PURGE_TOKEN", &["BUNNY_ACCESS_KEY"]),
313 None,
314 ),
315 CdnProvider::None => (
316 env_with_fallback("CDN_PURGE_TOKEN", &[]),
317 env_with_fallback("CDN_PURGE_ZONE", &[]),
318 ),
319 };
320
321 Self {
322 url,
323 provider,
324 purge_token,
325 purge_zone,
326 provider_error,
327 }
328 }
329
330 pub fn build_purge_api(&self) -> Result<Option<Box<dyn PurgeApi>>, Error> {
336 if let Some(bad) = &self.provider_error {
338 return Err(Error::cdn_invalid_provider(bad));
339 }
340
341 match &self.provider {
342 CdnProvider::None => {
343 tracing::info!("CDN_PROVIDER=none — purge is a no-op");
344 Ok(None)
345 }
346 CdnProvider::DigitalOcean => {
347 let cfg = DoSpacesCdnConfig {
348 endpoint_id: self.purge_zone.clone(),
349 api_token: self.purge_token.clone().unwrap_or_default(),
350 api_base: None,
351 };
352 Ok(Some(Box::new(DoSpacesCdn::new(cfg))))
353 }
354 CdnProvider::Bunny => {
355 #[cfg(feature = "cdn-bunny")]
356 {
357 let cfg = BunnyCdnConfig {
358 cdn_base_url: self.url.clone().unwrap_or_default(),
359 access_key: self.purge_token.clone().unwrap_or_default(),
360 };
361 Ok(Some(Box::new(BunnyCdn::new(cfg))))
362 }
363 #[cfg(not(feature = "cdn-bunny"))]
364 {
365 Err(Error::cdn_feature_required("bunny", "cdn-bunny"))
366 }
367 }
368 CdnProvider::Cloudflare => {
369 #[cfg(feature = "cdn-cloudflare")]
370 {
371 let cfg = CloudflareCdnConfig {
372 zone_id: self.purge_zone.clone().unwrap_or_default(),
373 api_token: self.purge_token.clone().unwrap_or_default(),
374 cdn_base_url: self.url.clone().unwrap_or_default(),
375 };
376 Ok(Some(Box::new(CloudflareCdn::new(cfg))))
377 }
378 #[cfg(not(feature = "cdn-cloudflare"))]
379 {
380 Err(Error::cdn_feature_required("cloudflare", "cdn-cloudflare"))
381 }
382 }
383 }
384 }
385}
386
387#[cfg(feature = "cdn-bunny")]
388pub mod bunny;
389#[cfg(feature = "cdn-bunny")]
390pub use bunny::{BunnyCdn, BunnyCdnConfig};
391
392#[cfg(feature = "cdn-cloudflare")]
393pub mod cloudflare;
394#[cfg(feature = "cdn-cloudflare")]
395pub use cloudflare::{CloudflareCdn, CloudflareCdnConfig};
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use wiremock::matchers::{body_json, header, method, path_regex};
401 use wiremock::{Mock, MockServer, ResponseTemplate};
402
403 fn cfg(server_uri: &str, id: Option<&str>, token: &str) -> DoSpacesCdnConfig {
405 DoSpacesCdnConfig {
406 endpoint_id: id.map(String::from),
407 api_token: token.to_string(),
408 api_base: Some(server_uri.to_string()),
409 }
410 }
411
412 #[test]
415 fn debug_does_not_contain_token() {
416 let config = DoSpacesCdnConfig {
417 endpoint_id: Some("ep-123".into()),
418 api_token: "secret-token-abc".into(),
419 api_base: None,
420 };
421 let dbg = format!("{config:?}");
422 assert!(
423 !dbg.contains("secret-token-abc"),
424 "Debug output must not contain the token: {dbg}"
425 );
426 assert!(
427 dbg.contains("<redacted>"),
428 "Debug output must show <redacted>: {dbg}"
429 );
430 }
431
432 #[tokio::test]
436 async fn do_adapter_request_shape() {
437 let server = MockServer::start().await;
438 Mock::given(method("DELETE"))
439 .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
440 .and(header("Authorization", "Bearer test-token"))
441 .and(body_json(serde_json::json!({ "files": ["index.html"] })))
442 .respond_with(ResponseTemplate::new(204))
443 .expect(1)
444 .mount(&server)
445 .await;
446
447 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
448 purger.purge(&["index.html".to_string()]).await.unwrap();
449 }
451
452 #[tokio::test]
454 async fn do_adapter_batches_over_50() {
455 let server = MockServer::start().await;
456 Mock::given(method("DELETE"))
457 .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
458 .and(header("Authorization", "Bearer test-token"))
459 .respond_with(ResponseTemplate::new(204))
460 .expect(2)
461 .mount(&server)
462 .await;
463
464 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
465 let paths: Vec<String> = (0..55).map(|i| format!("file{i}.html")).collect();
466 purger.purge(&paths).await.unwrap();
467 }
468
469 #[tokio::test]
472 async fn do_adapter_wildcard_slot() {
473 let server = MockServer::start().await;
474 Mock::given(method("DELETE"))
475 .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
476 .and(header("Authorization", "Bearer test-token"))
477 .respond_with(ResponseTemplate::new(204))
478 .expect(2)
479 .mount(&server)
480 .await;
481
482 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
483 let mut paths: Vec<String> = (0..50).map(|i| format!("file{i}.html")).collect();
484 paths.push("dir/*".to_string()); purger.purge(&paths).await.unwrap();
486 }
487
488 #[tokio::test]
490 async fn do_adapter_noop_missing_id() {
491 let server = MockServer::start().await;
492 Mock::given(method("DELETE"))
494 .respond_with(ResponseTemplate::new(204))
495 .expect(0)
496 .mount(&server)
497 .await;
498
499 let purger = DoSpacesCdn::new(cfg(&server.uri(), None, "test-token"));
500 purger.purge(&["a".to_string()]).await.unwrap();
501 }
502
503 #[tokio::test]
505 async fn purge_empty_noop() {
506 let server = MockServer::start().await;
507 Mock::given(method("DELETE"))
508 .respond_with(ResponseTemplate::new(204))
509 .expect(0)
510 .mount(&server)
511 .await;
512
513 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
514 purger.purge(&[]).await.unwrap();
515 }
516
517 #[tokio::test]
519 async fn do_adapter_error_on_non_204() {
520 let server = MockServer::start().await;
521 Mock::given(method("DELETE"))
522 .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
523 .respond_with(ResponseTemplate::new(403))
524 .mount(&server)
525 .await;
526
527 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
528 let err = purger.purge(&["index.html".to_string()]).await.unwrap_err();
529 assert!(
530 err.to_string().contains("403"),
531 "Error must contain status 403: {err}"
532 );
533 }
534
535 #[test]
538 fn cdn_config_debug_redacts_token() {
539 let config = Config {
540 purge_token: Some("secret-xyz".into()),
541 ..Default::default()
542 };
543 let dbg = format!("{config:?}");
544 assert!(
545 !dbg.contains("secret-xyz"),
546 "Debug output must not contain the token: {dbg}"
547 );
548 assert!(
549 dbg.contains("<redacted>"),
550 "Debug output must show <redacted>: {dbg}"
551 );
552 }
553
554 #[test]
555 fn cdn_invalid_provider() {
556 let result = CdnProvider::from_str_ci("Fastly");
557 assert!(result.is_err(), "Expected Err for unknown provider");
558 let msg = result.unwrap_err().to_string();
559 assert!(
560 msg.contains("none, digitalocean, bunny, cloudflare"),
561 "Error must list valid values: {msg}"
562 );
563 }
564
565 #[test]
568 fn cdn_invalid_provider_from_env_errors() {
569 let config = Config {
570 provider_error: Some("Fastly".into()),
571 ..Default::default()
572 };
573 let result = config.build_purge_api();
574 let err = match result {
575 Err(e) => e,
576 Ok(_) => panic!("build_purge_api must return Err when provider_error is set"),
577 };
578 let msg = err.to_string();
579 assert!(
580 msg.contains("none, digitalocean, bunny, cloudflare"),
581 "Error must list valid values: {msg}"
582 );
583 }
584
585 #[test]
586 fn cdn_provider_none_no_op() {
587 let config = Config::default();
588 assert!(
589 matches!(config.build_purge_api(), Ok(None)),
590 "provider=None must return Ok(None)"
591 );
592 }
593
594 #[test]
595 #[serial_test::serial]
596 fn cdn_config_from_env_quartet() {
597 std::env::set_var("CDN_URL", "https://q.example.com");
598 std::env::set_var("CDN_PROVIDER", "digitalocean");
599 std::env::set_var("CDN_PURGE_TOKEN", "tok");
600 std::env::set_var("CDN_PURGE_ZONE", "ep-9");
601
602 let config = Config::from_env();
603
604 std::env::remove_var("CDN_URL");
605 std::env::remove_var("CDN_PROVIDER");
606 std::env::remove_var("CDN_PURGE_TOKEN");
607 std::env::remove_var("CDN_PURGE_ZONE");
608
609 assert_eq!(config.url, Some("https://q.example.com".into()));
610 assert_eq!(config.provider, CdnProvider::DigitalOcean);
611 assert_eq!(config.purge_token, Some("tok".into()));
612 assert_eq!(config.purge_zone, Some("ep-9".into()));
613 }
614
615 #[test]
616 #[serial_test::serial]
617 fn cdn_fallback_aws_cdn_url() {
618 std::env::remove_var("CDN_URL");
619 std::env::set_var("AWS_CDN_URL", "https://legacy");
620
621 let config = Config::from_env();
622
623 std::env::remove_var("AWS_CDN_URL");
624
625 assert_eq!(config.url, Some("https://legacy".into()));
626 }
627
628 #[test]
629 #[serial_test::serial]
630 fn cdn_fallback_do_zone_and_token() {
631 std::env::remove_var("CDN_PURGE_ZONE");
632 std::env::remove_var("CDN_PURGE_TOKEN");
633 std::env::remove_var("CDN_PROVIDER");
634 std::env::set_var("DO_SPACES_CDN_ID", "ep-1");
635 std::env::set_var("DIGITALOCEAN_ACCESS_TOKEN", "t");
636
637 let config = Config::from_env();
638
639 std::env::remove_var("DO_SPACES_CDN_ID");
640 std::env::remove_var("DIGITALOCEAN_ACCESS_TOKEN");
641
642 assert_eq!(config.purge_zone, Some("ep-1".into()));
643 assert_eq!(config.purge_token, Some("t".into()));
644 assert_eq!(config.provider, CdnProvider::DigitalOcean);
645 }
646
647 #[cfg(not(feature = "cdn-bunny"))]
648 #[test]
649 fn cdn_feature_required_bunny() {
650 let config = Config {
651 provider: CdnProvider::Bunny,
652 ..Default::default()
653 };
654 let result = config.build_purge_api();
655 assert!(
656 result.is_err(),
657 "Expected Err when cdn-bunny feature is off"
658 );
659 let msg = match result {
660 Err(e) => e.to_string(),
661 Ok(_) => unreachable!(),
662 };
663 assert!(
664 msg.contains("cdn-bunny"),
665 "Error must mention the cdn-bunny feature: {msg}"
666 );
667 }
668
669 #[tokio::test]
672 async fn do_adapter_missing_token_errors() {
673 let server = MockServer::start().await;
674 Mock::given(method("DELETE"))
675 .respond_with(ResponseTemplate::new(204))
676 .expect(0)
677 .mount(&server)
678 .await;
679
680 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), ""));
681 let err = purger.purge(&["index.html".to_string()]).await.unwrap_err();
682 assert!(
683 err.to_string().contains("DIGITALOCEAN_ACCESS_TOKEN"),
684 "Error must mention the token env var: {err}"
685 );
686 }
687
688 #[tokio::test]
695 async fn do_adapter_throttle_serializes() {
696 let server = MockServer::start().await;
697 Mock::given(method("DELETE"))
698 .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
699 .and(header("Authorization", "Bearer test-token"))
700 .respond_with(ResponseTemplate::new(204))
701 .expect(6)
702 .mount(&server)
703 .await;
704
705 let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
706 let paths: Vec<String> = (0..300).map(|i| format!("file{i}.html")).collect();
708
709 let start = std::time::Instant::now();
710 purger.purge(&paths).await.unwrap();
711 let elapsed = start.elapsed();
712
713 assert!(
714 elapsed >= Duration::from_secs(9),
715 "Throttle should have held the 6th request for ~10s; elapsed: {elapsed:?}"
716 );
717 }
718}