max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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. |