max / makenotwork
4 files changed,
+108 insertions,
-8 deletions
| @@ -13,10 +13,14 @@ | |||
| 13 | 13 | ALTER TABLE projects ADD COLUMN IF NOT EXISTS cover_s3_key TEXT; | |
| 14 | 14 | ||
| 15 | 15 | -- Backfill from the canonical URL form: strip scheme + host (and any query | |
| 16 | - | -- string) to recover the object key. Stored cover URLs are always | |
| 17 | - | -- `{cdn_base}/{s3_key}`, so the path after the host IS the key. Only touch rows | |
| 18 | - | -- that have a URL but no key yet; a URL that isn't an http(s) path with a | |
| 19 | - | -- non-empty path is left NULL (nothing for the worker to protect). | |
| 16 | + | -- string) to recover the object key. Stored cover URLs are `{cdn_base}/{s3_key}`, | |
| 17 | + | -- so the path after the host IS the key. This holds because production REQUIRES | |
| 18 | + | -- a CDN (Config::from_env returns MissingCdnBaseUrl otherwise, ultra-fuzz Run 10 | |
| 19 | + | -- Sto S-1); the no-CDN dev fallback stores path-style presigned URLs | |
| 20 | + | -- (`{endpoint}/{bucket}/{key}`) that this regex would mis-key, but that mode is | |
| 21 | + | -- not allowed in production where this backfill runs. Only touch rows that have a | |
| 22 | + | -- URL but no key yet; a URL that isn't an http(s) path with a non-empty path is | |
| 23 | + | -- left NULL (nothing for the worker to protect). | |
| 20 | 24 | UPDATE projects | |
| 21 | 25 | SET cover_s3_key = substring(cover_image_url from '^https?://[^/]+/([^?]*)') | |
| 22 | 26 | WHERE cover_image_url IS NOT NULL |
| @@ -331,8 +331,22 @@ impl Config { | |||
| 331 | 331 | let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok(); | |
| 332 | 332 | let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok(); | |
| 333 | 333 | ||
| 334 | - | // CDN base URL - optional, when unset all downloads use presigned S3 URLs | |
| 335 | - | let cdn_base_url = std::env::var("CDN_BASE_URL").ok(); | |
| 334 | + | // CDN base URL — REQUIRED in production, presigned-S3 fallback in dev. | |
| 335 | + | // Without a CDN, cover/download URLs are path-style presigned S3 URLs | |
| 336 | + | // (`{endpoint}/{bucket}/{key}`), a shape the cover_s3_key backfill | |
| 337 | + | // (migration 152) and other key-from-URL derivation do not expect. The | |
| 338 | + | // platform assumes a CDN; refuse to start "green" in production without | |
| 339 | + | // one rather than silently run an unsupported mode (ultra-fuzz Run 10 Sto S-1). | |
| 340 | + | let cdn_base_url = std::env::var("CDN_BASE_URL").ok().filter(|s| !s.is_empty()); | |
| 341 | + | { | |
| 342 | + | let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) | |
| 343 | + | || std::env::var("HOST_URL") | |
| 344 | + | .map(|u| u.starts_with("https://")) | |
| 345 | + | .unwrap_or(false); | |
| 346 | + | if is_production && cdn_base_url.is_none() { | |
| 347 | + | return Err(ConfigError::MissingCdnBaseUrl); | |
| 348 | + | } | |
| 349 | + | } | |
| 336 | 350 | ||
| 337 | 351 | let user_pages_host = std::env::var("USER_PAGES_HOST") | |
| 338 | 352 | .ok() | |
| @@ -672,6 +686,8 @@ pub enum ConfigError { | |||
| 672 | 686 | WeakInternalSecret, | |
| 673 | 687 | #[error("CLI_SERVICE_TOKEN must be at least 32 characters long")] | |
| 674 | 688 | WeakCliServiceToken, | |
| 689 | + | #[error("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.")] | |
| 690 | + | MissingCdnBaseUrl, | |
| 675 | 691 | } | |
| 676 | 692 | ||
| 677 | 693 | #[cfg(test)] | |
| @@ -855,6 +871,45 @@ mod tests { | |||
| 855 | 871 | } | |
| 856 | 872 | ||
| 857 | 873 | #[test] | |
| 874 | + | fn from_env_fails_in_production_without_cdn_base_url() { | |
| 875 | + | let guard = EnvGuard::new(); | |
| 876 | + | guard.clear_all(); | |
| 877 | + | ||
| 878 | + | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 879 | + | unsafe { | |
| 880 | + | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 881 | + | std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate | |
| 882 | + | std::env::set_var("HOST", "0.0.0.0"); // production indicator | |
| 883 | + | // CDN_BASE_URL deliberately unset. | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | let err = Config::from_env().unwrap_err(); | |
| 887 | + | assert!( | |
| 888 | + | matches!(err, ConfigError::MissingCdnBaseUrl), | |
| 889 | + | "expected MissingCdnBaseUrl, got: {err}" | |
| 890 | + | ); | |
| 891 | + | drop(guard); | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | #[test] | |
| 895 | + | fn from_env_accepts_production_with_cdn_base_url() { | |
| 896 | + | let guard = EnvGuard::new(); | |
| 897 | + | guard.clear_all(); | |
| 898 | + | ||
| 899 | + | // SAFETY: test-only, serialized by EnvGuard mutex | |
| 900 | + | unsafe { | |
| 901 | + | std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); | |
| 902 | + | std::env::set_var("SIGNING_SECRET", "x".repeat(32)); | |
| 903 | + | std::env::set_var("HOST", "0.0.0.0"); | |
| 904 | + | std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work"); | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | let config = Config::from_env().expect("production config with CDN should succeed"); | |
| 908 | + | assert_eq!(config.cdn_base_url.as_deref(), Some("https://cdn.makenot.work")); | |
| 909 | + | drop(guard); | |
| 910 | + | } | |
| 911 | + | ||
| 912 | + | #[test] | |
| 858 | 913 | fn from_env_fails_with_https_host_url_without_signing_secret() { | |
| 859 | 914 | let guard = EnvGuard::new(); | |
| 860 | 915 | guard.clear_all(); |
| @@ -188,3 +188,40 @@ pub async fn get_stale_pending( | |||
| 188 | 188 | .await?; | |
| 189 | 189 | Ok(rows) | |
| 190 | 190 | } | |
| 191 | + | ||
| 192 | + | #[cfg(test)] | |
| 193 | + | mod tests { | |
| 194 | + | use super::*; | |
| 195 | + | use std::collections::BTreeSet; | |
| 196 | + | ||
| 197 | + | /// Pin the set of S3 key-bearing columns the deletion-worker liveness check | |
| 198 | + | /// consults. A new key-bearing column added anywhere must be registered in | |
| 199 | + | /// `S3_KEY_REFS` too, or a freshly re-uploaded object reusing a queued key | |
| 200 | + | /// could be torpedoed by the worker. This forces any change to be deliberate | |
| 201 | + | /// (ultra-fuzz Run 10 Sto N-1). | |
| 202 | + | #[test] | |
| 203 | + | fn s3_key_ref_registry_covers_every_known_class() { | |
| 204 | + | let actual: BTreeSet<(&str, &str, &str)> = | |
| 205 | + | S3_KEY_REFS.iter().map(|r| (r.bucket, r.table, r.column)).collect(); | |
| 206 | + | let expected: BTreeSet<(&str, &str, &str)> = [ | |
| 207 | + | ("main", "media_files", "s3_key"), | |
| 208 | + | ("main", "versions", "s3_key"), | |
| 209 | + | ("main", "items", "audio_s3_key"), | |
| 210 | + | ("main", "items", "cover_s3_key"), | |
| 211 | + | ("main", "items", "video_s3_key"), | |
| 212 | + | ("main", "projects", "cover_s3_key"), | |
| 213 | + | ("main", "item_images", "s3_key"), | |
| 214 | + | ("main", "project_images", "s3_key"), | |
| 215 | + | ("main", "content_insertions", "storage_key"), | |
| 216 | + | ("synckit", "sync_blobs", "s3_key"), | |
| 217 | + | ("synckit", "ota_artifacts", "s3_key"), | |
| 218 | + | ] | |
| 219 | + | .into_iter() | |
| 220 | + | .collect(); | |
| 221 | + | assert_eq!( | |
| 222 | + | actual, expected, | |
| 223 | + | "S3_KEY_REFS changed: if you added or removed a key-bearing column, update this \ | |
| 224 | + | test and confirm the deletion-worker liveness check still covers every key class" | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | } |
| @@ -127,8 +127,12 @@ pub(super) async fn version_confirm_upload( | |||
| 127 | 127 | return Err(AppError::Forbidden); | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | - | // Validate S3 key belongs to this user + item (prevent cross-user file reference) | |
| 131 | - | let expected_prefix = format!("{}/{}/", user.id, version.item_id); | |
| 130 | + | // Validate the S3 key belongs to this user + item + THIS version. The | |
| 131 | + | // generated key is `{user}/{item}/download/{version_id}/{filename}`, so pin | |
| 132 | + | // the full prefix including the version segment — a looser `{user}/{item}/` | |
| 133 | + | // check would let a creator confirm one version's row against another | |
| 134 | + | // version's key (ultra-fuzz Run 10 Sto M-1). | |
| 135 | + | let expected_prefix = format!("{}/{}/download/{}/", user.id, version.item_id, version_id); | |
| 132 | 136 | if !req.s3_key.starts_with(&expected_prefix) { | |
| 133 | 137 | return Err(AppError::BadRequest( | |
| 134 | 138 | "Invalid upload key".to_string(), |