max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
20 files changed,
+316 insertions,
-41 deletions
| @@ -879,6 +879,7 @@ | |||
| 879 | 879 | signing_secret: "secret".to_string(), | |
| 880 | 880 | storage: None, | |
| 881 | 881 | synckit_storage: None, | |
| 882 | + | public_storage: None, | |
| 882 | 883 | stripe: None, | |
| 883 | 884 | admin_user_id: Some(user.id), | |
| 884 | 885 | synckit_jwt_secret: None, | |
| @@ -950,6 +951,7 @@ | |||
| 950 | 951 | signing_secret: "secret".to_string(), | |
| 951 | 952 | storage: None, | |
| 952 | 953 | synckit_storage: None, | |
| 954 | + | public_storage: None, | |
| 953 | 955 | stripe: None, | |
| 954 | 956 | admin_user_id: None, | |
| 955 | 957 | synckit_jwt_secret: None, |
| @@ -23,6 +23,11 @@ | |||
| 23 | 23 | pub storage: Option<StorageConfig>, | |
| 24 | 24 | /// Separate S3 bucket for SyncKit blob storage (optional) | |
| 25 | 25 | pub synckit_storage: Option<StorageConfig>, | |
| 26 | + | /// Public, CDN-served bucket for promoted image content (covers, gallery, | |
| 27 | + | /// item/project images). Same endpoint/credentials as `storage`, bucket | |
| 28 | + | /// overridden by `S3_PUBLIC_BUCKET`. Required in production (the CDN serves | |
| 29 | + | /// ONLY this bucket); `None` in dev when `S3_PUBLIC_BUCKET` is unset. | |
| 30 | + | pub public_storage: Option<StorageConfig>, | |
| 26 | 31 | /// Stripe payment configuration (optional) | |
| 27 | 32 | pub stripe: Option<StripeConfig>, | |
| 28 | 33 | /// Admin user ID for waitlist management (optional) | |
| @@ -220,6 +225,21 @@ | |||
| 220 | 225 | // Load SyncKit blob storage config - separate S3 bucket | |
| 221 | 226 | let synckit_storage = StorageConfig::from_env_prefixed("SYNCKIT_S3_"); | |
| 222 | 227 | ||
| 228 | + | // Public, CDN-served bucket: reuse the main storage endpoint/credentials | |
| 229 | + | // with the bucket overridden by S3_PUBLIC_BUCKET. Only the immutably-public | |
| 230 | + | // promoted image content lands here, so it carries a blanket public-read | |
| 231 | + | // policy while the main bucket stays private. `None` when either the main | |
| 232 | + | // storage or S3_PUBLIC_BUCKET is unset (dev); required in production below. | |
| 233 | + | let public_storage = std::env::var("S3_PUBLIC_BUCKET") | |
| 234 | + | .ok() | |
| 235 | + | .filter(|s| !s.is_empty()) | |
| 236 | + | .and_then(|bucket| { | |
| 237 | + | storage.as_ref().map(|s| StorageConfig { | |
| 238 | + | bucket, | |
| 239 | + | ..s.clone() | |
| 240 | + | }) | |
| 241 | + | }); | |
| 242 | + | ||
| 223 | 243 | // Load Stripe config - optional, returns None if not fully configured | |
| 224 | 244 | let stripe = StripeConfig::from_env(); | |
| 225 | 245 | ||
| @@ -359,6 +379,13 @@ | |||
| 359 | 379 | if is_production && cdn_base_url.is_none() { | |
| 360 | 380 | return Err(ConfigError::MissingCdnBaseUrl); | |
| 361 | 381 | } | |
| 382 | + | // The CDN serves ONLY the public bucket; without it, promoted image | |
| 383 | + | // content has nowhere to land and covers/gallery would 404. Storage | |
| 384 | + | // must be configured (checked implicitly: public_storage is Some only | |
| 385 | + | // when both S3_PUBLIC_BUCKET and the main storage are set). | |
| 386 | + | if is_production && storage.is_some() && public_storage.is_none() { | |
| 387 | + | return Err(ConfigError::MissingPublicBucket); | |
| 388 | + | } | |
| 362 | 389 | } | |
| 363 | 390 | ||
| 364 | 391 | let user_pages_host = std::env::var("USER_PAGES_HOST") | |
| @@ -421,6 +448,7 @@ | |||
| 421 | 448 | signing_secret, | |
| 422 | 449 | storage, | |
| 423 | 450 | synckit_storage, | |
| 451 | + | public_storage, | |
| 424 | 452 | stripe, | |
| 425 | 453 | admin_user_id, | |
| 426 | 454 | synckit_jwt_secret, | |
| @@ -740,6 +768,8 @@ | |||
| 740 | 768 | WeakCliServiceToken, | |
| 741 | 769 | #[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.")] | |
| 742 | 770 | MissingCdnBaseUrl, | |
| 771 | + | #[error("S3_PUBLIC_BUCKET is required in production when storage is configured. The CDN serves ONLY the public bucket; promoted image content (covers, gallery, item/project images) is copied there. Set S3_PUBLIC_BUCKET to the public, world-readable bucket name.")] | |
| 772 | + | MissingPublicBucket, | |
| 743 | 773 | } | |
| 744 | 774 | ||
| 745 | 775 | #[cfg(test)] | |
| @@ -756,6 +786,7 @@ | |||
| 756 | 786 | const CONFIG_ENV_VARS: &[&str] = &[ | |
| 757 | 787 | "HOST", "PORT", "DATABASE_URL", "HOST_URL", "SIGNING_SECRET", | |
| 758 | 788 | "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION", | |
| 789 | + | "S3_PUBLIC_BUCKET", | |
| 759 | 790 | "SYNCKIT_S3_ENDPOINT", "SYNCKIT_S3_BUCKET", "SYNCKIT_S3_ACCESS_KEY", | |
| 760 | 791 | "SYNCKIT_S3_SECRET_KEY", "SYNCKIT_S3_REGION", | |
| 761 | 792 | "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET_V2", | |
| @@ -829,6 +860,7 @@ | |||
| 829 | 860 | signing_secret: "secret".to_string(), | |
| 830 | 861 | storage: None, | |
| 831 | 862 | synckit_storage: None, | |
| 863 | + | public_storage: None, | |
| 832 | 864 | stripe: None, | |
| 833 | 865 | admin_user_id: None, | |
| 834 | 866 | synckit_jwt_secret: None, |
| @@ -82,6 +82,10 @@ | |||
| 82 | 82 | pub config: Config, | |
| 83 | 83 | pub s3: Option<Arc<dyn StorageBackend>>, | |
| 84 | 84 | pub synckit_s3: Option<Arc<dyn StorageBackend>>, | |
| 85 | + | /// Public, CDN-served bucket backend. Holds only promoted image content | |
| 86 | + | /// (covers, gallery, item/project images); the scan worker copies Clean | |
| 87 | + | /// image objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset. | |
| 88 | + | pub public_s3: Option<Arc<dyn StorageBackend>>, | |
| 85 | 89 | pub stripe: Option<Arc<dyn PaymentProvider>>, | |
| 86 | 90 | pub email: EmailClient, | |
| 87 | 91 | pub docs: Arc<DocLoader>, | |
| @@ -153,6 +157,13 @@ | |||
| 153 | 157 | .ok_or_else(|| error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string())) | |
| 154 | 158 | } | |
| 155 | 159 | ||
| 160 | + | /// Get the public (CDN-served) S3 storage backend, or error if not configured. | |
| 161 | + | pub fn require_public_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> { | |
| 162 | + | self.public_s3 | |
| 163 | + | .as_ref() | |
| 164 | + | .ok_or_else(|| error::AppError::ServiceUnavailable("Public storage bucket is not configured".to_string())) | |
| 165 | + | } | |
| 166 | + | ||
| 156 | 167 | /// Delete a user account and purge every derived in-memory cache keyed to it. | |
| 157 | 168 | /// | |
| 158 | 169 | /// This is the single deletion entry point for handlers and the scheduler. The |
| @@ -220,6 +220,25 @@ | |||
| 220 | 220 | None | |
| 221 | 221 | }; | |
| 222 | 222 | ||
| 223 | + | // Initialize public (CDN-served) bucket client if configured. Holds only | |
| 224 | + | // promoted image content; the scan worker copies Clean covers/gallery here. | |
| 225 | + | let public_s3: Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> = | |
| 226 | + | if let Some(ref public_storage_config) = config.public_storage { | |
| 227 | + | match S3Client::new(public_storage_config, &config.host_url).await { | |
| 228 | + | Ok(client) => { | |
| 229 | + | tracing::info!(bucket = %public_storage_config.bucket, "Public S3 bucket initialized"); | |
| 230 | + | Some(std::sync::Arc::new(client)) | |
| 231 | + | } | |
| 232 | + | Err(e) => { | |
| 233 | + | tracing::warn!(error = ?e, "Failed to initialize public S3 bucket"); | |
| 234 | + | None | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } else { | |
| 238 | + | tracing::info!("Public S3 bucket not configured"); | |
| 239 | + | None | |
| 240 | + | }; | |
| 241 | + | ||
| 223 | 242 | // Initialize Stripe client if configured | |
| 224 | 243 | let stripe: Option<std::sync::Arc<dyn makenotwork::payments::PaymentProvider>> = if let Some(ref stripe_config) = config.stripe { | |
| 225 | 244 | match StripeClient::new(stripe_config) { | |
| @@ -374,6 +393,7 @@ | |||
| 374 | 393 | config: config.clone(), | |
| 375 | 394 | s3, | |
| 376 | 395 | synckit_s3, | |
| 396 | + | public_s3, | |
| 377 | 397 | stripe, | |
| 378 | 398 | email, | |
| 379 | 399 | docs, | |
| @@ -433,6 +453,7 @@ | |||
| 433 | 453 | cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(), | |
| 434 | 454 | cdn_base_url: state.config.cdn_base_url.as_deref().map(std::sync::Arc::from), | |
| 435 | 455 | synckit_s3: state.synckit_s3.clone(), | |
| 456 | + | public_s3: state.public_s3.clone(), | |
| 436 | 457 | }); | |
| 437 | 458 | let worker_count = makenotwork::constants::SCAN_WORKER_COUNT; | |
| 438 | 459 | let worker_shutdown_rx = shutdown_tx.subscribe(); |
| @@ -331,6 +331,13 @@ | |||
| 331 | 331 | pub enum S3Bucket { | |
| 332 | 332 | Main, | |
| 333 | 333 | Synckit, | |
| 334 | + | /// Public, CDN-served bucket (`cdn.makenot.work`). Holds ONLY the | |
| 335 | + | /// immutably-public image kinds after promote (covers, gallery, item/project | |
| 336 | + | /// images, content insertions); a paid object can never enter it, so its | |
| 337 | + | /// blanket public-read policy is safe by construction. Staging is never here | |
| 338 | + | /// — unscanned bytes stay in `Main`; the content object lands here only via | |
| 339 | + | /// the cross-bucket promote (see `scanning::promote_staging_to_content`). | |
| 340 | + | Public, | |
| 334 | 341 | } | |
| 335 | 342 | ||
| 336 | 343 | impl S3Bucket { | |
| @@ -339,6 +346,7 @@ | |||
| 339 | 346 | match self { | |
| 340 | 347 | S3Bucket::Main => "main", | |
| 341 | 348 | S3Bucket::Synckit => "synckit", | |
| 349 | + | S3Bucket::Public => "public", | |
| 342 | 350 | } | |
| 343 | 351 | } | |
| 344 | 352 | ||
| @@ -348,11 +356,30 @@ | |||
| 348 | 356 | pub fn from_db_str(s: &str) -> Self { | |
| 349 | 357 | match s { | |
| 350 | 358 | "synckit" => S3Bucket::Synckit, | |
| 359 | + | "public" => S3Bucket::Public, | |
| 351 | 360 | _ => S3Bucket::Main, | |
| 352 | 361 | } | |
| 353 | 362 | } | |
| 354 | 363 | } | |
| 355 | 364 | ||
| 365 | + | /// Deletion enqueue pair for a content-image key whose promote state is unknown. | |
| 366 | + | /// | |
| 367 | + | /// A CDN-image key is a private **staging** key (`staging/…`, in `Main`) until | |
| 368 | + | /// [`crate::scanning::promote_staging_to_content`] repoints it to the public | |
| 369 | + | /// **content** key (`{user}/c/{sha}.ext`, in `Public`). A given object is in | |
| 370 | + | /// EXACTLY one bucket, but a delete/replace path can run in either state, so it | |
| 371 | + | /// can't know which. Enqueue the key under BOTH buckets: the reaper deletes from | |
| 372 | + | /// the bucket the object is in and no-ops the other (content keys are unique to | |
| 373 | + | /// one bucket), and `is_s3_key_live` still guards each bucket against a live | |
| 374 | + | /// reference. Only for the four CDN-served image surfaces; gated media | |
| 375 | + | /// (audio/video/version/media) is always `Main` and insertions always `Main`. | |
| 376 | + | pub fn both_bucket_delete(key: &str) -> [(String, String); 2] { | |
| 377 | + | [ | |
| 378 | + | (key.to_string(), S3Bucket::Main.as_str().to_string()), | |
| 379 | + | (key.to_string(), S3Bucket::Public.as_str().to_string()), | |
| 380 | + | ] | |
| 381 | + | } | |
| 382 | + | ||
| 356 | 383 | /// Aggregate a `ByteStream` into memory, aborting once more than `max_bytes` | |
| 357 | 384 | /// have been read. Backs [`StorageBackend::download_object_buf_capped`]; factored | |
| 358 | 385 | /// out as a free function so the cap logic is unit-testable without a full | |
| @@ -473,6 +500,12 @@ | |||
| 473 | 500 | /// HIGH). Required (not defaulted): a silent no-op default would make a | |
| 474 | 501 | /// promote "succeed" while the served key stays empty. | |
| 475 | 502 | async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()>; | |
| 503 | + | /// Server-side copy from `src_bucket` into THIS backend's bucket. The | |
| 504 | + | /// cross-bucket half of scan-then-promote: a Clean staging object in the | |
| 505 | + | /// private bucket is lifted into the public (CDN-served) bucket. Call on the | |
| 506 | + | /// public backend with the private bucket name as `src_bucket`. Required | |
| 507 | + | /// (not defaulted) for the same reason as `copy_object`. | |
| 508 | + | async fn copy_object_from(&self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key) -> Result<()>; | |
| 476 | 509 | async fn check_connectivity(&self) -> std::result::Result<(), String>; | |
| 477 | 510 | fn bucket(&self) -> &str; | |
| 478 | 511 | } | |
| @@ -837,6 +870,16 @@ | |||
| 837 | 870 | .map_err(AppError::Storage) | |
| 838 | 871 | } | |
| 839 | 872 | ||
| 873 | + | /// Server-side copy from `src_bucket` into this client's bucket. See the | |
| 874 | + | /// [`StorageBackend::copy_object_from`] trait method for the cross-bucket | |
| 875 | + | /// promote rationale. | |
| 876 | + | pub async fn copy_object_from(&self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { | |
| 877 | + | self.inner | |
| 878 | + | .copy_object_from(src_bucket, src_key.as_str(), dst_key.as_str()) | |
| 879 | + | .await | |
| 880 | + | .map_err(AppError::Storage) | |
| 881 | + | } | |
| 882 | + | ||
| 840 | 883 | /// Batched S3 delete (`DeleteObjects`, up to 1000 keys per call). | |
| 841 | 884 | /// Chunks larger slices into 1000-key batches and logs per-key failures | |
| 842 | 885 | /// without bubbling — the pending_s3_deletions queue is the safety net. | |
| @@ -1083,6 +1126,11 @@ | |||
| 1083 | 1126 | self.delete_objects(keys).await | |
| 1084 | 1127 | } | |
| 1085 | 1128 | ||
| 1129 | + | async fn copy_object_from(&self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { | |
| 1130 | + | // Inherent method; delegate. | |
| 1131 | + | self.copy_object_from(src_bucket, src_key, dst_key).await | |
| 1132 | + | } | |
| 1133 | + | ||
| 1086 | 1134 | async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { | |
| 1087 | 1135 | // Inherent method; delegate. | |
| 1088 | 1136 | self.copy_object(src_key, dst_key).await |
| @@ -105,13 +105,20 @@ | |||
| 105 | 105 | S3KeyRef { bucket: "main", table: "media_files", column: "s3_key" }, | |
| 106 | 106 | S3KeyRef { bucket: "main", table: "versions", column: "s3_key" }, | |
| 107 | 107 | S3KeyRef { bucket: "main", table: "items", column: "audio_s3_key" }, | |
| 108 | - | S3KeyRef { bucket: "main", table: "items", column: "cover_s3_key" }, | |
| 109 | 108 | S3KeyRef { bucket: "main", table: "items", column: "video_s3_key" }, | |
| 110 | - | // Covers store the bare key directly (migration 152 added projects.cover_s3_key; | |
| 111 | - | // items.cover_s3_key predates it). | |
| 112 | - | S3KeyRef { bucket: "main", table: "projects", column: "cover_s3_key" }, | |
| 113 | - | S3KeyRef { bucket: "main", table: "item_images", column: "s3_key" }, | |
| 114 | - | S3KeyRef { bucket: "main", table: "project_images", column: "s3_key" }, | |
| 109 | + | // ── public bucket: CDN-served, unsigned image content ── | |
| 110 | + | // These four surfaces render straight from `cdn.makenot.work/{key}`, so after | |
| 111 | + | // promote their content object lives in the public bucket, NOT main (see | |
| 112 | + | // `ScanTargetKind::content_served_from_public_bucket`). Their staging object | |
| 113 | + | // is still in main, deleted under the `main` tag by the promote — only the | |
| 114 | + | // promoted content object is reaped against `public`. Covers store the bare | |
| 115 | + | // key directly (migration 152 added projects.cover_s3_key; items.cover_s3_key | |
| 116 | + | // predates it). | |
| 117 | + | S3KeyRef { bucket: "public", table: "items", column: "cover_s3_key" }, | |
| 118 | + | S3KeyRef { bucket: "public", table: "projects", column: "cover_s3_key" }, | |
| 119 | + | S3KeyRef { bucket: "public", table: "item_images", column: "s3_key" }, | |
| 120 | + | S3KeyRef { bucket: "public", table: "project_images", column: "s3_key" }, | |
| 121 | + | // content_insertions is served PRESIGNED from the private bucket, so it stays main. | |
| 115 | 122 | S3KeyRef { bucket: "main", table: "content_insertions", column: "storage_key" }, | |
| 116 | 123 | // ── synckit bucket: SyncKit blobs + OTA artifacts (both deterministic keys) ── | |
| 117 | 124 | S3KeyRef { bucket: "synckit", table: "sync_blobs", column: "s3_key" }, | |
| @@ -207,12 +214,13 @@ | |||
| 207 | 214 | ("main", "media_files", "s3_key"), | |
| 208 | 215 | ("main", "versions", "s3_key"), | |
| 209 | 216 | ("main", "items", "audio_s3_key"), | |
| 210 | - | ("main", "items", "cover_s3_key"), | |
| 211 | 217 | ("main", "items", "video_s3_key"), | |
| 212 | - | ("main", "projects", "cover_s3_key"), | |
| 213 | - | ("main", "item_images", "s3_key"), | |
| 214 | - | ("main", "project_images", "s3_key"), | |
| 215 | 218 | ("main", "content_insertions", "storage_key"), | |
| 219 | + | // CDN-served image content lives in the public bucket post-promote. | |
| 220 | + | ("public", "items", "cover_s3_key"), | |
| 221 | + | ("public", "projects", "cover_s3_key"), | |
| 222 | + | ("public", "item_images", "s3_key"), | |
| 223 | + | ("public", "project_images", "s3_key"), | |
| 216 | 224 | ("synckit", "sync_blobs", "s3_key"), | |
| 217 | 225 | ("synckit", "ota_artifacts", "s3_key"), | |
| 218 | 226 | ] |
| @@ -101,6 +101,24 @@ | |||
| 101 | 101 | ) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | /// Whether this kind's *content* object (post-promote) is served UNSIGNED | |
| 105 | + | /// from the public CDN bucket, and so must be promoted cross-bucket into | |
| 106 | + | /// [`crate::storage::S3Bucket::Public`]. This is the three image cover/gallery | |
| 107 | + | /// kinds — the `is_cdn_served_without_gate` set MINUS `ContentInsertion`, | |
| 108 | + | /// which, despite carrying no per-request scan gate, is served *presigned* | |
| 109 | + | /// from the private bucket (see `promote_cdn_image_by_key`: it alone has no | |
| 110 | + | /// materialized public URL). The matching `S3_KEY_REFS` entries | |
| 111 | + | /// (`item_images`, `project_images`, `items.cover_s3_key`, | |
| 112 | + | /// `projects.cover_s3_key`) therefore live under bucket `public`, while | |
| 113 | + | /// `content_insertions.storage_key` stays `main`. Staging is ALWAYS private | |
| 114 | + | /// ([`storage_bucket`] is unchanged) — only the promoted content object moves. | |
| 115 | + | pub fn content_served_from_public_bucket(&self) -> bool { | |
| 116 | + | matches!( | |
| 117 | + | self, | |
| 118 | + | ScanTargetKind::ItemImage | ScanTargetKind::ProjectImage | ScanTargetKind::GalleryImage | |
| 119 | + | ) | |
| 120 | + | } | |
| 121 | + | ||
| 104 | 122 | /// Whether a `Quarantined` verdict purges the underlying S3 object. | |
| 105 | 123 | /// | |
| 106 | 124 | /// Always true: a confirmed-malicious object has no reason to remain in |