Skip to main content

max / makenotwork

Require CDN_BASE_URL everywhere so no cover URL can expire build_project_image_url fell back to a 24-hour presigned S3 URL when no CDN was configured, and its callers persist the result into projects.cover_image_url, a column that is read forever. Both project covers in production are broken today because of it. Production could not reach the fallback (config refuses to start without CDN_BASE_URL there), but dev and staging could, and it was one relaxed check away from returning. Make it unrepresentable rather than unreachable. Config::cdn_base_url is a String, CDN_BASE_URL is required in every environment, and build_project_image_url is sync, infallible, and has no presign branch. Removing the Option surfaced the rest. Eight sites resolved a missing base to the literal https://cdn.makenot.work, so dev and staging rendered creator markdown media and the item, project and library pages against production's CDN host; media.rs did the same behind a warn-once. The wizard's cover-URL validation was skipped entirely without a CDN, so the check that keeps a hostile URL out of an <img src> sitewide did not run in dev at all. extract_s3_key_from_url takes &str now and guards on non-empty, because strip_prefix("") succeeds on any input and an empty base must match nothing rather than everything. The scan worker's quarantine edge-purge no longer no-ops on a missing base. Prod already has CDN_BASE_URL set, so this adds no new required var there. A dev or staging env file without it now fails startup, which MNW_CHECK_CONFIG=1 catches before a deploy swaps the symlink. The two bad prod rows are not fixed by this; they need their covers re-uploaded, which also corrects their bucket-prefixed s3 keys.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 19:44 UTC
Signed with PGP, not checked
Commit: 3e3b1d15105c5df39ad642a7d684f63f099e6770
Parent: 5d4ff1f
23 files changed, +128 insertions, -150 deletions
@@ -24,8 +24,10 @@
24 24 # S3_ACCESS_KEY=
25 25 # S3_SECRET_KEY=
26 26
27 - # Optional: CDN for free content downloads (Cloudflare-proxied)
28 - # CDN_BASE_URL=https://cdn.makenot.work
27 + # REQUIRED. Render base for every public image and media URL. In production this
28 + # is the Cloudflare-proxied edge in front of the public bucket. In dev, point it
29 + # straight at the public bucket's origin; there is no unset mode.
30 + CDN_BASE_URL=https://cdn.makenot.work
29 31
30 32 # Optional: Email (Phase 8)
31 33 # SMTP_HOST=
@@ -1003,7 +1003,7 @@
1003 1003 admin_user_id: Some(user.id),
1004 1004 synckit_jwt_secret: None,
1005 1005 scan: None,
1006 - cdn_base_url: None,
1006 + cdn_base_url: "https://cdn.localhost".to_string(),
1007 1007 user_pages_host: std::sync::Arc::from("u.localhost"),
1008 1008 access_gate: crate::config::AccessGate::Open,
1009 1009 sso: None,
@@ -1084,7 +1084,7 @@
1084 1084 admin_user_id: None,
1085 1085 synckit_jwt_secret: None,
1086 1086 scan: None,
1087 - cdn_base_url: None,
1087 + cdn_base_url: "https://cdn.localhost".to_string(),
1088 1088 user_pages_host: std::sync::Arc::from("u.localhost"),
1089 1089 access_gate: crate::config::AccessGate::Open,
1090 1090 sso: None,
@@ -37,8 +37,10 @@
37 37 /// File scanning configuration (optional)
38 38 pub scan: Option<ScanConfig>,
39 39 /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work").
40 - /// When set, free content downloads are served via CDN instead of presigned S3 URLs.
41 - pub cdn_base_url: Option<String>,
40 + /// Required in every environment: it is the only render base for public
41 + /// image and media URLs. Point it at the raw public-bucket origin in dev if
42 + /// there is no edge in front.
43 + pub cdn_base_url: String,
42 44 /// Hostname that serves creator custom pages (e.g. "u.makenot.work").
43 45 /// Cookieless and strict-CSP, isolated from the apex. Defaults to "u." +
44 46 /// the host_url host; override via USER_PAGES_HOST.
@@ -383,19 +385,22 @@
383 385 let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok();
384 386 let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok();
385 387
386 - // CDN base URL, REQUIRED in production, presigned-S3 fallback in dev.
387 - // Without a CDN, cover/download URLs are path-style presigned S3 URLs
388 + // CDN base URL, REQUIRED everywhere. Without one, cover/download URLs
389 + // used to fall back to path-style presigned S3 URLs
388 390 // (`{endpoint}/{bucket}/{key}`), a shape the cover_s3_key backfill
389 - // (migration 152) and other key-from-URL derivation do not expect. The
390 - // platform assumes a CDN; refuse to start "green" in production without
391 - // one rather than silently run an unsupported mode (ultra-fuzz Run 10 Sto S-1).
392 - let cdn_base_url = std::env::var("CDN_BASE_URL").ok().filter(|s| !s.is_empty());
391 + // (migration 152) and other key-from-URL derivation do not expect. Worse,
392 + // that fallback minted a 24-hour URL for `projects.cover_image_url`, a
393 + // durable column, so every cover written on it died a day later. The
394 + // requirement is unconditional rather than production-only so the trap
395 + // cannot exist at all: dev points CDN_BASE_URL at the raw public-bucket
396 + // origin when there is no edge in front (ultra-fuzz Run 10 Sto S-1).
397 + let cdn_base_url = std::env::var("CDN_BASE_URL")
398 + .ok()
399 + .filter(|s| !s.is_empty())
400 + .ok_or(ConfigError::MissingCdnBaseUrl)?;
393 401 {
394 402 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
395 403 || std::env::var("HOST_URL").is_ok_and(|u| u.starts_with("https://"));
396 - if is_production && cdn_base_url.is_none() {
397 - return Err(ConfigError::MissingCdnBaseUrl);
398 - }
399 404 // The CDN serves ONLY the public bucket; without it, promoted image
400 405 // content has nowhere to land and covers/gallery would 404. Storage
401 406 // must be configured (checked implicitly: public_storage is Some only
@@ -530,7 +535,7 @@
530 535 if let Some(apex) = host_of(&self.host_url) {
531 536 hosts.push(apex);
532 537 }
533 - if let Some(cdn) = self.cdn_base_url.as_deref().and_then(host_of) {
538 + if let Some(cdn) = host_of(&self.cdn_base_url) {
534 539 hosts.push(cdn);
535 540 }
536 541 let base = format!("https://{}/", self.user_pages_host);
@@ -892,7 +897,7 @@
892 897 #[error("ALERTS_INGEST_TOKEN must be at least 32 characters long")]
893 898 WeakAlertsIngestToken,
894 899 #[error(
895 - "CDN_BASE_URL is required in production (HOST=0.0.0.0 or HTTPS HOST_URL detected). Without a CDN, cover/download URLs are presigned S3 URLs the storage key-derivation logic does not support. Set CDN_BASE_URL to your CDN origin."
900 + "CDN_BASE_URL is required. It is the render base for every public image and media URL, and without it covers used to fall back to a 24-hour presigned URL written into a durable column. In dev, point it at the public bucket's origin. Set CDN_BASE_URL to your CDN origin."
896 901 )]
897 902 MissingCdnBaseUrl,
898 903 #[error(
@@ -1041,7 +1046,7 @@
1041 1046 admin_user_id: None,
1042 1047 synckit_jwt_secret: None,
1043 1048 scan: None,
1044 - cdn_base_url: None,
1049 + cdn_base_url: "https://cdn.localhost".to_string(),
1045 1050 user_pages_host: Arc::from("u.localhost"),
1046 1051 access_gate: AccessGate::Open,
1047 1052 sso: None,
@@ -1100,6 +1105,7 @@
1100 1105 // SAFETY: test-only, serialized by EnvGuard mutex
1101 1106 unsafe {
1102 1107 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1108 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1103 1109 }
1104 1110
1105 1111 let config = Config::from_env().expect("should succeed with DATABASE_URL set");
@@ -1133,6 +1139,7 @@
1133 1139 // SAFETY: test-only, serialized by EnvGuard mutex
1134 1140 unsafe {
1135 1141 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1142 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1136 1143 std::env::set_var("HOST", "0.0.0.0"); // production indicator
1137 1144 }
1138 1145
@@ -1145,7 +1152,7 @@
1145 1152 }
1146 1153
1147 1154 #[test]
1148 - fn from_env_fails_in_production_without_cdn_base_url() {
1155 + fn from_env_fails_without_cdn_base_url_even_outside_production() {
1149 1156 let guard = EnvGuard::new();
1150 1157 EnvGuard::clear_all();
1151 1158
@@ -1153,7 +1160,10 @@
1153 1160 unsafe {
1154 1161 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1155 1162 std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate
1156 - std::env::set_var("HOST", "0.0.0.0"); // production indicator
1163 + // No production indicator: HOST stays unset, so this is a dev config.
1164 + // It must STILL fail. The requirement is unconditional precisely so
1165 + // no environment can reach the old presigned fallback, which minted
1166 + // a 24-hour URL into the durable `projects.cover_image_url` column.
1157 1167 // CDN_BASE_URL deliberately unset.
1158 1168 }
1159 1169
@@ -1173,16 +1183,14 @@
1173 1183 // SAFETY: test-only, serialized by EnvGuard mutex
1174 1184 unsafe {
1175 1185 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1186 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1176 1187 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1177 1188 std::env::set_var("HOST", "0.0.0.0");
1178 1189 std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work");
1179 1190 }
1180 1191
1181 1192 let config = Config::from_env().expect("production config with CDN should succeed");
1182 - assert_eq!(
1183 - config.cdn_base_url.as_deref(),
1184 - Some("https://cdn.makenot.work")
1185 - );
1193 + assert_eq!(config.cdn_base_url, "https://cdn.makenot.work");
1186 1194 drop(guard);
1187 1195 }
1188 1196
@@ -1194,6 +1202,7 @@
1194 1202 // SAFETY: test-only, serialized by EnvGuard mutex
1195 1203 unsafe {
1196 1204 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1205 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1197 1206 std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator
1198 1207 }
1199 1208
@@ -1213,6 +1222,7 @@
1213 1222 // SAFETY: test-only, serialized by EnvGuard mutex
1214 1223 unsafe {
1215 1224 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1225 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1216 1226 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1217 1227 // 31 chars, one under the floor.
1218 1228 std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31));
@@ -1234,6 +1244,7 @@
1234 1244 // SAFETY: test-only, serialized by EnvGuard mutex
1235 1245 unsafe {
1236 1246 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1247 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1237 1248 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1238 1249 std::env::set_var("SYNCKIT_JWT_SECRET", "y".repeat(32));
1239 1250 }
@@ -1254,6 +1265,7 @@
1254 1265 // SAFETY: test-only, serialized by EnvGuard mutex
1255 1266 unsafe {
1256 1267 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1268 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1257 1269 // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET
1258 1270 }
1259 1271
@@ -1281,6 +1293,7 @@
1281 1293 // SAFETY: test-only, serialized by EnvGuard mutex
1282 1294 unsafe {
1283 1295 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1296 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1284 1297 // Set only some S3 vars, missing S3_SECRET_KEY and S3_ACCESS_KEY
1285 1298 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
1286 1299 std::env::set_var("S3_BUCKET", "test-bucket");
@@ -1302,6 +1315,7 @@
1302 1315 // SAFETY: test-only, serialized by EnvGuard mutex
1303 1316 unsafe {
1304 1317 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1318 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1305 1319 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
1306 1320 std::env::set_var("S3_BUCKET", "test-bucket");
1307 1321 std::env::set_var("S3_ACCESS_KEY", "ak");
@@ -1326,6 +1340,7 @@
1326 1340 // SAFETY: test-only, serialized by EnvGuard mutex
1327 1341 unsafe {
1328 1342 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1343 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1329 1344 // Set webhook secret but not secret key
1330 1345 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
1331 1346 }
@@ -1346,6 +1361,7 @@
1346 1361 // SAFETY: test-only, serialized by EnvGuard mutex
1347 1362 unsafe {
1348 1363 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1364 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1349 1365 // Set secret key but not webhook secret
1350 1366 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
1351 1367 }
@@ -1366,6 +1382,7 @@
1366 1382 // SAFETY: test-only, serialized by EnvGuard mutex
1367 1383 unsafe {
1368 1384 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1385 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1369 1386 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
1370 1387 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
1371 1388 }
@@ -1388,6 +1405,7 @@
1388 1405 // SAFETY: test-only, serialized by EnvGuard mutex
1389 1406 unsafe {
1390 1407 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1408 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1391 1409 std::env::set_var("HOST", "not-an-ip");
1392 1410 }
1393 1411
@@ -1407,6 +1425,7 @@
1407 1425 // SAFETY: test-only, serialized by EnvGuard mutex
1408 1426 unsafe {
1409 1427 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1428 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1410 1429 std::env::set_var("PORT", "not-a-number");
1411 1430 }
1412 1431
@@ -1426,6 +1445,7 @@
1426 1445 // SAFETY: test-only, serialized by EnvGuard mutex
1427 1446 unsafe {
1428 1447 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1448 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1429 1449 std::env::set_var("SCAN_ENABLED", "false");
1430 1450 }
1431 1451
@@ -1445,6 +1465,7 @@
1445 1465 // SAFETY: test-only, serialized by EnvGuard mutex
1446 1466 unsafe {
1447 1467 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1468 + std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1448 1469 }
1449 1470
1450 1471 let config = Config::from_env().expect("should succeed");
@@ -10,6 +10,7 @@
10 10 pub mod auth;
11 11 pub mod background;
12 12 pub mod build_runner;
13 + pub mod changelog;
13 14 pub mod cloudflare;
14 15 pub mod config;
15 16 pub mod constants;
@@ -721,7 +722,7 @@
721 722 // Build CSP with storage and payment domains
722 723 let s3_origin = std::env::var("S3_ENDPOINT").unwrap_or_default();
723 724 let s3_origin = s3_origin.as_str();
724 - let cdn = state.config.cdn_base_url.as_deref().unwrap_or("");
725 + let cdn = state.config.cdn_base_url.as_str();
725 726 let storage_origins = match (s3_origin.is_empty(), cdn.is_empty()) {
726 727 (false, false) => format!(" {s3_origin} {cdn}"),
727 728 (false, true) => format!(" {s3_origin}"),
@@ -234,7 +234,7 @@
234 234 let media = makenotwork::seed::SeedMedia {
235 235 s3: seed_client(config.storage.as_ref(), &config.host_url).await,
236 236 public_s3: seed_client(config.public_storage.as_ref(), &config.host_url).await,
237 - cdn_base_url: config.cdn_base_url.clone(),
237 + cdn_base_url: Some(config.cdn_base_url.clone()),
238 238 };
239 239
240 240 match makenotwork::seed::run(&db, &opts, &media).await {
@@ -546,6 +546,11 @@
546 546 let scheduler_handle =
547 547 makenotwork::scheduler::spawn_scheduler(state.clone(), scheduler_shutdown_rx);
548 548
549 + // Per-instance (not scheduler-gated, so every instance in a rolling deploy
550 + // agrees): decides whether the footer links to /changelog at all.
551 + let _changelog_handle =
552 + makenotwork::changelog::spawn_refresher(state.db.clone(), shutdown_tx.subscribe());
553 +
549 554 // Start scan worker pool. Only meaningful if a scanner is configured;
550 555 // otherwise enqueue_scan_for never enqueues (trust-gate fast path).
551 556 if let (Some(scanner), Some(s3_for_workers)) = (state.scanner.clone(), state.storage.s3.clone())
@@ -558,11 +563,7 @@
558 563 wam: state.wam.clone(),
559 564 bg: state.bg.clone(),
560 565 cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(),
561 - cdn_base_url: state
562 - .config
563 - .cdn_base_url
564 - .as_deref()
565 - .map(std::sync::Arc::from),
566 + cdn_base_url: std::sync::Arc::from(state.config.cdn_base_url.as_str()),
566 567 synckit_s3: state.storage.synckit_s3.clone(),
567 568 public_s3: state.storage.public_s3.clone(),
568 569 });
@@ -1276,15 +1276,18 @@
1276 1276 /// bucket eliminates the heuristic entirely.
1277 1277 pub fn extract_s3_key_from_url(
1278 1278 url: &str,
1279 - cdn_base: Option<&str>,
1279 + cdn_base: &str,
1280 1280 bucket: Option<&str>,
1281 1281 s3_endpoint: Option<&str>,
1282 1282 ) -> Option<String> {
1283 1283 let no_query = url.split('?').next()?;
1284 1284
1285 - // Try CDN-base prefix first.
1286 - if let Some(base) = cdn_base {
1287 - let base = base.trim_end_matches('/');
1285 + // Try CDN-base prefix first. An empty base matches nothing rather than
1286 + // matching everything: `strip_prefix("")` succeeds on any input, so the
1287 + // guard is what keeps a caller that passes "" from harvesting a key out of
1288 + // an arbitrary host.
1289 + if !cdn_base.is_empty() {
1290 + let base = cdn_base.trim_end_matches('/');
1288 1291 if let Some(rest) = no_query.strip_prefix(base)
1289 1292 && let Some(key) = rest.strip_prefix('/')
1290 1293 && !key.is_empty()
@@ -1312,17 +1315,14 @@
1312 1315 }
1313 1316
1314 1317 /// Build a permanent URL for a project image.
1315 - /// CDN configured: permanent CDN URL. No CDN: 24-hour presigned S3 URL.
1316 - pub async fn build_project_image_url(
1317 - s3: &dyn StorageBackend,
1318 - cdn_base_url: Option<&str>,
1319 - s3_key: &str,
1320 - ) -> Result<String> {
1321 - if let Some(cdn_base) = cdn_base_url {
1322 - return Ok(format!("{cdn_base}/{s3_key}"));
1323 - }
1324 - s3.presign_download(&S3Key::from_stored(s3_key), Some(86400))
1325 - .await
1318 + ///
1319 + /// Permanent is the whole contract: callers persist the result into
1320 + /// `projects.cover_image_url`, which is read forever. There is deliberately no
1321 + /// presigned fallback — an expiring URL in a durable column is the bug this
1322 + /// signature exists to make unrepresentable. `cdn_base` is required config
1323 + /// (`Config::cdn_base_url`), so there is nothing to fall back to.
1324 + pub fn build_project_image_url(cdn_base: &str, s3_key: &str) -> String {
1325 + format!("{cdn_base}/{s3_key}")
1326 1326 }
1327 1327
1328 1328 #[async_trait::async_trait]
@@ -1526,7 +1526,7 @@
1526 1526 fn extract_key_cdn_form() {
1527 1527 let key = extract_s3_key_from_url(
1528 1528 "https://cdn.makenot.work/projects/abc/image/cover.png",
1529 - Some("https://cdn.makenot.work"),
1529 + "https://cdn.makenot.work",
1530 1530 None,
1531 1531 None,
1532 1532 );
@@ -1537,7 +1537,7 @@
1537 1537 fn extract_key_cdn_with_trailing_slash_in_base() {
1538 1538 let key = extract_s3_key_from_url(
1539 1539 "https://cdn.makenot.work/foo/bar",
1540 - Some("https://cdn.makenot.work/"),
1540 + "https://cdn.makenot.work/",
1541 1541 None,
1542 1542 None,
1543 1543 );
@@ -1548,7 +1548,7 @@
1548 1548 fn extract_key_strips_query_string() {
1549 1549 let key = extract_s3_key_from_url(
1550 1550 "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz",
1551 - Some("https://cdn.makenot.work"),
1551 + "https://cdn.makenot.work",
1552 1552 None,
1553 1553 None,
1554 1554 );
@@ -1559,7 +1559,7 @@
1559 1559 fn extract_key_path_style_s3() {
1560 1560 let key = extract_s3_key_from_url(
1561 1561 "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...",
1562 - None,
1562 + "",
1563 1563 Some("my-bucket"),
1564 1564 Some("https://fsn1.your-objectstorage.com"),
1565 1565 );
@@ -1572,7 +1572,7 @@
1572 1572 // path must NOT be accepted. The endpoint pin closes the gap.
1573 1573 let key = extract_s3_key_from_url(
1574 1574 "https://attacker.example/my-bucket/poisoned",
1575 - None,
1575 + "",
1576 1576 Some("my-bucket"),
1577 1577 Some("https://fsn1.your-objectstorage.com"),
1578 1578 );
@@ -1585,7 +1585,7 @@
1585 1585 // name alone is not enough to identify a trustworthy host.
1586 1586 let key = extract_s3_key_from_url(
1587 1587 "https://fsn1.your-objectstorage.com/my-bucket/u/123/key",
1588 - None,
1588 + "",
1589 1589 Some("my-bucket"),
1590 1590 None,
1591 1591 );
@@ -1597,7 +1597,7 @@
1597 1597 // Neither the CDN base nor the bucket name is present in the URL.
1598 1598 let key = extract_s3_key_from_url(
1599 1599 "https://random.example.com/foo/bar",
1600 - Some("https://cdn.makenot.work"),
1600 + "https://cdn.makenot.work",
1601 1601 Some("my-bucket"),
1602 1602 Some("https://fsn1.your-objectstorage.com"),
1603 1603 );
@@ -1610,7 +1610,7 @@
1610 1610 // "projects/x" from this URL, dropping the user-scoped prefix.
1611 1611 let key = extract_s3_key_from_url(
1612 1612 "https://cdn.makenot.work/u/me/projects/x",
1613 - Some("https://cdn.makenot.work"),
1613 + "https://cdn.makenot.work",
1614 1614 None,
1615 1615 None,
1616 1616 );
@@ -505,7 +505,7 @@
505 505 /// though, must be embeddable in the apex editor iframe, so it allows exactly
506 506 /// the apex origin to frame it, nothing else.
507 507 fn apply_security_headers(headers: &mut HeaderMap, config: &Config, is_preview: bool) {
508 - let cdn = config.cdn_base_url.as_deref().unwrap_or("");
508 + let cdn = config.cdn_base_url.as_str();
509 509 let media_src = if cdn.is_empty() {
510 510 "'self'".to_string()
511 511 } else {
@@ -277,7 +277,7 @@
277 277 db: &sqlx::PgPool,
278 278 backend: &dyn crate::storage::StorageBackend,
279 279 public_backend: Option<&dyn crate::storage::StorageBackend>,
280 - cdn_base_url: Option<&str>,
280 + cdn_base_url: &str,
281 281 kind: crate::db::scan_jobs::ScanTargetKind,
282 282 file_type: FileType,
283 283 target_id: uuid::Uuid,
@@ -383,12 +383,9 @@
383 383 // No materialized URL column (insertions are served presigned).
384 384 String::new()
385 385 } else {
386 - // Content object is in the public bucket; build its URL against the
387 - // public backend so the dev (no-CDN) presign fallback targets the
388 - // right bucket. In production `cdn_base_url` is set, so this is
389 - // `{cdn}/{content_key}` regardless of backend.
390 - let url_backend = public_backend.unwrap_or(backend);
391 - crate::storage::build_project_image_url(url_backend, cdn_base_url, &content_str).await?
386 + // Content object is in the public bucket, which is exactly what the
387 + // CDN base fronts, so the URL is `{cdn}/{content_key}`.
388 + crate::storage::build_project_image_url(cdn_base_url, &content_str)
392 389 };
393 390 crate::db::scanning::promote_cdn_image_by_key(
394 391 &mut tx,
@@ -96,8 +96,8 @@
96 96 /// an already-cached malicious copy stops serving before its TTL lapses.
97 97 pub cloudflare: Option<crate::cloudflare::CloudflarePurger>,
98 98 /// CDN base URL (e.g. "https://cdn.makenot.work"), used to build the purge
99 - /// target for a quarantined object's public URL. `None` disables purging.
100 - pub cdn_base_url: Option<Arc<str>>,
99 + /// target for a quarantined object's public URL.
100 + pub cdn_base_url: Arc<str>,
101 101 /// SyncKit-bucket backend. OTA artifacts live here (not the main `s3`), so a
102 102 /// `ScanTargetKind::OtaArtifact` job downloads from this backend instead.
103 103 /// `None` when SyncKit storage isn't configured (OTA scans then fail closed).
@@ -570,10 +570,10 @@
570 570 // immutably for up to a year, so a previously-fetched malicious URL keeps
571 571 // serving from the edge until its TTL lapses. Fire-and-forget (the CF API
572 572 // has its own latency and must not stall enforcement); a no-op when the
573 - // purger / cdn base aren't configured (the WAM ticket stays the fallback).
573 + // purger isn't configured (the WAM ticket stays the fallback).
574 574 // Purging a never-cached URL is harmless, so we don't gate on kind.
575 - if let (Some(cf), Some(base)) = (ctx.cloudflare.clone(), ctx.cdn_base_url.clone()) {
576 - let url = format!("{}/{}", base.trim_end_matches('/'), job.s3_key);
575 + if let Some(cf) = ctx.cloudflare.clone() {
576 + let url = format!("{}/{}", ctx.cdn_base_url.trim_end_matches('/'), job.s3_key);
577 577 ctx.bg.spawn("malware quarantine cdn purge", async move {
578 578 cf.purge_urls(vec![url]).await;
579 579 });
@@ -648,7 +648,7 @@
648 648 &ctx.db,
649 649 backend.as_ref(),
650 650 ctx.public_s3.as_deref(),
651 - ctx.cdn_base_url.as_deref(),
651 + &ctx.cdn_base_url,
652 652 kind,
653 653 file_type,
654 654 job.target_id,
@@ -6,6 +6,10 @@
6 6 pub(crate) mod storage;
7 7 pub(crate) mod stripe;
8 8
9 + /// CDN render base every test app uses unless `Opts::cdn_base_url` overrides it.
10 + /// `Config::cdn_base_url` is required, so tests always have one.
11 + pub(crate) const TEST_CDN_BASE: &str = "https://cdn.test";
12 +
9 13 /// Compute SHA-256 hash of a SyncKit API key (mirrors server's hash_api_key).
10 14 pub(crate) fn hash_api_key(api_key: &str) -> String {
11 15 use sha2::Digest;
@@ -77,6 +81,8 @@
77 81 pub internal_shared_secret: Option<String>,
78 82 pub cli_service_token: Option<String>,
79 83 pub mock_email: Option<Arc<email::MockEmailTransport>>,
84 + /// Overrides the CDN render base. `None` uses [`TEST_CDN_BASE`]; the base is
85 + /// required config, so there is no "no CDN" mode to opt into.
80 86 pub cdn_base_url: Option<String>,
81 87 /// Site access gate. Defaults to `Open`; set to `FanPlusOrCreator` to test
82 88 /// the testnot-style gate.
@@ -335,7 +341,10 @@
335 341 admin_user_id: opts.admin_user_id,
336 342 synckit_jwt_secret: Some("test-synckit-jwt-secret".to_string()),
337 343 scan: None,
338 - cdn_base_url: opts.cdn_base_url.clone(),
344 + cdn_base_url: opts
345 + .cdn_base_url
346 + .clone()
347 + .unwrap_or_else(|| TEST_CDN_BASE.to_string()),
339 348 user_pages_host: std::sync::Arc::from("u.localhost"),
340 349 access_gate: opts.access_gate,
341 350 sso: opts.sso.clone(),
@@ -609,7 +618,7 @@
609 618 bg: makenotwork::background::spawn_pool_detached(),
610 619 // No Cloudflare purge in tests; quarantine still deletes from origin.
611 620 cloudflare: None,
612 - cdn_base_url: None,
621 + cdn_base_url: std::sync::Arc::from(TEST_CDN_BASE),
613 622 // OTA artifacts scan from the SyncKit bucket; tests share one backend.
614 623 synckit_s3: Some(deps.s3.clone()),
615 624 // Public bucket shares the same backend; image promotes copy here.