max / makenotwork
20 files changed,
+316 insertions,
-41 deletions
| @@ -879,6 +879,7 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ pub struct Config { | |||
| 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 @@ impl Config { | |||
| 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 @@ impl Config { | |||
| 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 @@ impl Config { | |||
| 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 @@ pub enum ConfigError { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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, |
| @@ -105,13 +105,20 @@ const S3_KEY_REFS: &[S3KeyRef] = &[ | |||
| 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 @@ mod tests { | |||
| 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 @@ impl ScanTargetKind { | |||
| 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 |
| @@ -82,6 +82,10 @@ pub struct AppState { | |||
| 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 @@ impl AppState { | |||
| 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 @@ async fn main() { | |||
| 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 @@ async fn main() { | |||
| 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 @@ async fn main() { | |||
| 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(); |
| @@ -363,14 +363,23 @@ pub(super) async fn delete_project( | |||
| 363 | 363 | let version_keys = db::items::get_project_version_s3_keys(&state.db, id).await?; | |
| 364 | 364 | let gallery_keys = db::gallery_images::s3_keys_for_project(&state.db, id).await?; | |
| 365 | 365 | ||
| 366 | - | // Include the project cover image if present | |
| 367 | - | let mut all_keys: Vec<(String, String)> = item_keys | |
| 368 | - | .into_iter() | |
| 369 | - | .chain(version_keys) | |
| 370 | - | .chain(gallery_keys) | |
| 371 | - | .map(|k| (k, "main".to_string())) | |
| 372 | - | .collect(); | |
| 366 | + | let mut all_keys: Vec<(String, String)> = Vec::new(); | |
| 367 | + | // Version downloads are gated media — always the private bucket. | |
| 368 | + | all_keys.extend( | |
| 369 | + | version_keys | |
| 370 | + | .into_iter() | |
| 371 | + | .map(|k| (k, crate::storage::S3Bucket::Main.as_str().to_string())), | |
| 372 | + | ); | |
| 373 | + | // Item keys (audio/video are private; the item cover is public) and gallery | |
| 374 | + | // images (public) are content-image-or-staging keys of unknown promote state. | |
| 375 | + | // Enqueue each under BOTH buckets; the reaper no-ops the bucket the object | |
| 376 | + | // isn't in (audio/video's public row and a promoted cover's main row are | |
| 377 | + | // harmless no-ops). See `both_bucket_delete`. | |
| 378 | + | for k in item_keys.into_iter().chain(gallery_keys) { | |
| 379 | + | all_keys.extend(crate::storage::both_bucket_delete(&k)); | |
| 380 | + | } | |
| 373 | 381 | ||
| 382 | + | // Include the project cover image if present (public bucket, or staging in main). | |
| 374 | 383 | if let Some(ref url) = project.cover_image_url | |
| 375 | 384 | && let Some(key) = crate::storage::extract_s3_key_from_url( | |
| 376 | 385 | url, | |
| @@ -379,7 +388,7 @@ pub(super) async fn delete_project( | |||
| 379 | 388 | state.config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 380 | 389 | ) | |
| 381 | 390 | { | |
| 382 | - | all_keys.push((key, "main".to_string())); | |
| 391 | + | all_keys.extend(crate::storage::both_bucket_delete(&key)); | |
| 383 | 392 | } | |
| 384 | 393 | ||
| 385 | 394 | // Enqueue for durable S3 deletion (survives crashes) BEFORE the CASCADE delete. |
| @@ -32,17 +32,23 @@ pub struct VersionDownloadResponse { | |||
| 32 | 32 | pub license_url: Option<String>, | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | /// Resolve a content URL: CDN for free content when configured, presigned S3 otherwise. | |
| 35 | + | /// Resolve a content URL for downloadable media. Always a presigned, expiring | |
| 36 | + | /// URL — even for free content. | |
| 37 | + | /// | |
| 38 | + | /// Downloadable media (audio/video/downloads) lives ONLY in the private bucket; | |
| 39 | + | /// it is never served unsigned from the CDN. Free content previously resolved to | |
| 40 | + | /// a permanent unsigned `{cdn}/{key}` URL, but that (a) required the media bucket | |
| 41 | + | /// to be publicly readable — which would expose paid content sharing the same | |
| 42 | + | /// bucket — and (b) made free->paid revocation impossible (the URL never | |
| 43 | + | /// expired). Presigning free media closes both: the private bucket stays private | |
| 44 | + | /// and a short-lived URL self-revokes. Only immutably-public IMAGE content | |
| 45 | + | /// (covers/gallery) is served unsigned, and that lives in the separate public | |
| 46 | + | /// bucket behind the CDN. | |
| 36 | 47 | async fn resolve_content_url( | |
| 37 | 48 | s3: &dyn crate::storage::StorageBackend, | |
| 38 | - | cdn_base_url: Option<&str>, | |
| 39 | 49 | s3_key: &str, | |
| 40 | - | is_free: bool, | |
| 41 | 50 | expiry_secs: u64, | |
| 42 | 51 | ) -> Result<(String, u64)> { | |
| 43 | - | if is_free && let Some(cdn_base) = cdn_base_url { | |
| 44 | - | return Ok((format!("{}/{}", cdn_base, s3_key), 0)); | |
| 45 | - | } | |
| 46 | 52 | let url = s3.presign_download(&crate::storage::S3Key::from_stored(s3_key), Some(expiry_secs)) | |
| 47 | 53 | .await | |
| 48 | 54 | .context("presign download for content")?; | |
| @@ -132,7 +138,7 @@ pub(super) async fn stream_url( | |||
| 132 | 138 | None => 3600, | |
| 133 | 139 | }; | |
| 134 | 140 | let (stream_url, expires_in) = resolve_content_url( | |
| 135 | - | s3.as_ref(), state.config.cdn_base_url.as_deref(), &s3_key, is_free, expiry_secs, | |
| 141 | + | s3.as_ref(), &s3_key, expiry_secs, | |
| 136 | 142 | ).await?; | |
| 137 | 143 | ||
| 138 | 144 | // Increment total play count (includes replays) | |
| @@ -218,7 +224,7 @@ pub(super) async fn version_download( | |||
| 218 | 224 | } | |
| 219 | 225 | ||
| 220 | 226 | let (download_url, expires_in) = resolve_content_url( | |
| 221 | - | s3.as_ref(), state.config.cdn_base_url.as_deref(), s3_key, is_free, 3600, | |
| 227 | + | s3.as_ref(), s3_key, 3600, | |
| 222 | 228 | ).await?; | |
| 223 | 229 | ||
| 224 | 230 | // Increment per-version and item-level download counts |
| @@ -413,7 +413,10 @@ pub(super) async fn gallery_delete( | |||
| 413 | 413 | db::creator_tiers::decrement_storage_used(&mut *tx, user.id, r.file_size_bytes).await?; | |
| 414 | 414 | db::pending_s3_deletions::enqueue_deletions( | |
| 415 | 415 | &mut *tx, | |
| 416 | - | &[(r.s3_key.clone(), "main".to_string())], | |
| 416 | + | // Gallery images (item_images/project_images) are CDN-served; the | |
| 417 | + | // key may be a private staging key or a public content key, so | |
| 418 | + | // enqueue both (see `both_bucket_delete`). | |
| 419 | + | &crate::storage::both_bucket_delete(&r.s3_key), | |
| 417 | 420 | "gallery_image_delete", | |
| 418 | 421 | ) | |
| 419 | 422 | .await?; |
| @@ -252,7 +252,12 @@ pub(super) async fn project_image_confirm( | |||
| 252 | 252 | if let Some(old_key) = old_key_to_delete.as_deref() { | |
| 253 | 253 | db::pending_s3_deletions::enqueue_deletions( | |
| 254 | 254 | &mut *tx, | |
| 255 | - | &[(old_key.to_string(), "main".to_string())], | |
| 255 | + | // A cover key is a private staging key until promote repoints it to | |
| 256 | + | // the public content key, so the old cover may be in EITHER bucket. | |
| 257 | + | // Enqueue both; the reaper no-ops the bucket the object isn't in | |
| 258 | + | // (content keys are unique to one bucket) and `is_s3_key_live` | |
| 259 | + | // still guards each bucket against a live reference. | |
| 260 | + | &crate::storage::both_bucket_delete(old_key), | |
| 256 | 261 | "project_image_replace", | |
| 257 | 262 | ) | |
| 258 | 263 | .await?; | |
| @@ -480,7 +485,9 @@ pub(super) async fn item_image_confirm( | |||
| 480 | 485 | if let Some(old_key) = old_key_to_delete.as_deref() { | |
| 481 | 486 | db::pending_s3_deletions::enqueue_deletions( | |
| 482 | 487 | &mut *tx, | |
| 483 | - | &[(old_key.to_string(), "main".to_string())], | |
| 488 | + | // Old cover may be a private staging key or a public content key; | |
| 489 | + | // enqueue both (see `both_bucket_delete`). | |
| 490 | + | &crate::storage::both_bucket_delete(old_key), | |
| 484 | 491 | "item_image_replace", | |
| 485 | 492 | ) | |
| 486 | 493 | .await?; |
| @@ -404,6 +404,9 @@ pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) - | |||
| 404 | 404 | crate::scanning::promote_staging_to_content( | |
| 405 | 405 | &state.db, | |
| 406 | 406 | s3.as_ref(), | |
| 407 | + | // Item audio is a gated Main-bucket kind; the public backend is | |
| 408 | + | // unused here but passed for signature consistency. | |
| 409 | + | state.public_s3.as_deref(), | |
| 407 | 410 | state.config.cdn_base_url.as_deref(), | |
| 408 | 411 | ScanTargetKind::Item, | |
| 409 | 412 | file_type, | |
| @@ -451,6 +454,8 @@ pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::Ver | |||
| 451 | 454 | crate::scanning::promote_staging_to_content( | |
| 452 | 455 | &state.db, | |
| 453 | 456 | s3.as_ref(), | |
| 457 | + | // Version downloads are a gated Main-bucket kind; public backend unused. | |
| 458 | + | state.public_s3.as_deref(), | |
| 454 | 459 | state.config.cdn_base_url.as_deref(), | |
| 455 | 460 | ScanTargetKind::Version, | |
| 456 | 461 | FileType::Download, |
| @@ -276,6 +276,7 @@ fn too_large_to_scan(file_size: u64) -> ScanResult { | |||
| 276 | 276 | pub async fn promote_staging_to_content( | |
| 277 | 277 | db: &sqlx::PgPool, | |
| 278 | 278 | backend: &dyn crate::storage::StorageBackend, | |
| 279 | + | public_backend: Option<&dyn crate::storage::StorageBackend>, | |
| 279 | 280 | cdn_base_url: Option<&str>, | |
| 280 | 281 | kind: crate::db::scan_jobs::ScanTargetKind, | |
| 281 | 282 | file_type: FileType, | |
| @@ -303,10 +304,29 @@ pub async fn promote_staging_to_content( | |||
| 303 | 304 | let content = S3Client::content_key(owner, sha256, ext); | |
| 304 | 305 | let content_str = content.as_str().to_string(); | |
| 305 | 306 | ||
| 307 | + | // The three CDN-unsigned image kinds have their content object served from | |
| 308 | + | // the PUBLIC bucket; everything else (gated media, presigned insertions, | |
| 309 | + | // OTA) keeps its content object in the same private bucket the staging | |
| 310 | + | // object lives in. Staging is always private, so a public-bucket promote is | |
| 311 | + | // a CROSS-bucket copy (private staging -> public content) issued on the | |
| 312 | + | // public backend with the private bucket as the copy source. | |
| 313 | + | let to_public = kind.content_served_from_public_bucket(); | |
| 314 | + | ||
| 306 | 315 | // 1. Promote the object in storage (copy staging -> content). Idempotent. | |
| 307 | - | backend | |
| 308 | - | .copy_object(&S3Key::from_stored(staging_key), &content) | |
| 309 | - | .await?; | |
| 316 | + | if to_public { | |
| 317 | + | let public = public_backend.ok_or_else(|| { | |
| 318 | + | crate::error::AppError::Storage(format!( | |
| 319 | + | "cannot promote CDN image {staging_key}: public bucket not configured" | |
| 320 | + | )) | |
| 321 | + | })?; | |
| 322 | + | public | |
| 323 | + | .copy_object_from(backend.bucket(), &S3Key::from_stored(staging_key), &content) | |
| 324 | + | .await?; | |
| 325 | + | } else { | |
| 326 | + | backend | |
| 327 | + | .copy_object(&S3Key::from_stored(staging_key), &content) | |
| 328 | + | .await?; | |
| 329 | + | } | |
| 310 | 330 | ||
| 311 | 331 | // 2. Repoint the row + enqueue the staging delete atomically. | |
| 312 | 332 | let mut tx = db.begin().await?; | |
| @@ -315,7 +335,12 @@ pub async fn promote_staging_to_content( | |||
| 315 | 335 | // No materialized URL column (insertions are served presigned). | |
| 316 | 336 | String::new() | |
| 317 | 337 | } else { | |
| 318 | - | crate::storage::build_project_image_url(backend, cdn_base_url, &content_str).await? | |
| 338 | + | // Content object is in the public bucket; build its URL against the | |
| 339 | + | // public backend so the dev (no-CDN) presign fallback targets the | |
| 340 | + | // right bucket. In production `cdn_base_url` is set, so this is | |
| 341 | + | // `{cdn}/{content_key}` regardless of backend. | |
| 342 | + | let url_backend = public_backend.unwrap_or(backend); | |
| 343 | + | crate::storage::build_project_image_url(url_backend, cdn_base_url, &content_str).await? | |
| 319 | 344 | }; | |
| 320 | 345 | crate::db::scanning::promote_cdn_image_by_key(&mut tx, staging_key, &content_str, &content_url) | |
| 321 | 346 | .await?; |
| @@ -99,6 +99,12 @@ pub struct WorkerContext { | |||
| 99 | 99 | /// `ScanTargetKind::OtaArtifact` job downloads from this backend instead. | |
| 100 | 100 | /// `None` when SyncKit storage isn't configured (OTA scans then fail closed). | |
| 101 | 101 | pub synckit_s3: Option<Arc<dyn StorageBackend>>, | |
| 102 | + | /// Public, CDN-served bucket backend. Staging + scanning always read from | |
| 103 | + | /// `s3`/`synckit_s3` (private); this backend is used ONLY to promote a Clean | |
| 104 | + | /// object of an `is_cdn_served_without_gate()` image kind cross-bucket into | |
| 105 | + | /// the public bucket. `None` when the public bucket isn't configured (image | |
| 106 | + | /// promotes then fail closed, leaving the entity on its unserved staging key). | |
| 107 | + | pub public_s3: Option<Arc<dyn StorageBackend>>, | |
| 102 | 108 | } | |
| 103 | 109 | ||
| 104 | 110 | /// Decide the final status for a non-quarantined scan. | |
| @@ -327,6 +333,16 @@ async fn run_pipeline_and_decide( | |||
| 327 | 333 | "SyncKit storage not configured; cannot scan OTA artifact", | |
| 328 | 334 | ) | |
| 329 | 335 | })?, | |
| 336 | + | // `storage_bucket()` is the STAGING/scan bucket and is never `Public` | |
| 337 | + | // (unscanned bytes stay private; only the promoted content object moves | |
| 338 | + | // to the public bucket — see `content_served_from_public_bucket`). Guard | |
| 339 | + | // it so a future routing change fails loudly rather than reading a | |
| 340 | + | // scan object from the wrong bucket. | |
| 341 | + | crate::storage::S3Bucket::Public => { | |
| 342 | + | return Err(Box::<dyn std::error::Error + Send + Sync>::from( | |
| 343 | + | "invariant: a staging/scan object must never live in the public bucket", | |
| 344 | + | )); | |
| 345 | + | } | |
| 330 | 346 | crate::storage::S3Bucket::Main => &ctx.s3, | |
| 331 | 347 | }; | |
| 332 | 348 | ||
| @@ -589,6 +605,7 @@ async fn run_pipeline_and_decide( | |||
| 589 | 605 | super::promote_staging_to_content( | |
| 590 | 606 | &ctx.db, | |
| 591 | 607 | backend.as_ref(), | |
| 608 | + | ctx.public_s3.as_deref(), | |
| 592 | 609 | ctx.cdn_base_url.as_deref(), | |
| 593 | 610 | kind, | |
| 594 | 611 | file_type, |
| @@ -114,8 +114,13 @@ async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event | |||
| 114 | 114 | let user_prefix = format!("{user_id}/"); | |
| 115 | 115 | let main = crate::storage::S3Bucket::Main.as_str().to_string(); | |
| 116 | 116 | let synckit = crate::storage::S3Bucket::Synckit.as_str().to_string(); | |
| 117 | + | let public = crate::storage::S3Bucket::Public.as_str().to_string(); | |
| 117 | 118 | let mut keys: Vec<(String, String)> = Vec::new(); | |
| 118 | 119 | keys.push((user_prefix.clone(), main.clone())); | |
| 120 | + | // The user's CDN-served image content (`{user_id}/c/{sha}`) lives in the | |
| 121 | + | // public bucket — same key prefix, different bucket — so sweep it there too | |
| 122 | + | // or those objects orphan on account deletion. | |
| 123 | + | keys.push((user_prefix.clone(), public.clone())); | |
| 119 | 124 | for pid in &project_ids { | |
| 120 | 125 | keys.push((format!("projects/{pid}/"), main.clone())); | |
| 121 | 126 | } | |
| @@ -143,6 +148,15 @@ async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event | |||
| 143 | 148 | } | |
| 144 | 149 | } | |
| 145 | 150 | ||
| 151 | + | // The user's CDN-served image content lives under the same `{user_id}/` | |
| 152 | + | // prefix in the public bucket; sweep it there too (the enqueue above is the | |
| 153 | + | // durable backstop if this fast path fails). | |
| 154 | + | if let Some(ref public_s3) = state.public_s3 | |
| 155 | + | && let Err(e) = public_s3.delete_prefix(&auth, &user_prefix).await | |
| 156 | + | { | |
| 157 | + | tracing::warn!(error = ?e, %user_id, "{label}: failed to delete user public-bucket objects"); | |
| 158 | + | } | |
| 159 | + | ||
| 146 | 160 | if let Some(ref synckit_s3) = state.synckit_s3 { | |
| 147 | 161 | for app in &sync_apps { | |
| 148 | 162 | let blob_prefix = format!("{}/", app.id); | |
| @@ -343,10 +357,13 @@ pub(super) async fn purge_expired_deleted_items(state: &AppState) { | |||
| 343 | 357 | // Collect S3 keys from items AND their versions before CASCADE delete destroys the data | |
| 344 | 358 | let mut all_s3_keys: Vec<(String, String)> = Vec::new(); | |
| 345 | 359 | ||
| 360 | + | // Item keys are audio/video (private bucket) mixed with the item cover | |
| 361 | + | // (public bucket); enqueue each under both so the reaper hits the right one | |
| 362 | + | // (see `both_bucket_delete`). | |
| 346 | 363 | match db::items::get_expired_deleted_item_s3_keys(&state.db).await { | |
| 347 | 364 | Ok(keys) => { | |
| 348 | 365 | for key in &keys { | |
| 349 | - | all_s3_keys.push((key.clone(), crate::storage::S3Bucket::Main.as_str().to_string())); | |
| 366 | + | all_s3_keys.extend(crate::storage::both_bucket_delete(key)); | |
| 350 | 367 | } | |
| 351 | 368 | } | |
| 352 | 369 | Err(e) => { | |
| @@ -357,6 +374,7 @@ pub(super) async fn purge_expired_deleted_items(state: &AppState) { | |||
| 357 | 374 | match db::items::get_expired_deleted_item_version_s3_keys(&state.db).await { | |
| 358 | 375 | Ok(keys) => { | |
| 359 | 376 | for key in &keys { | |
| 377 | + | // Version downloads are gated media — always the private bucket. | |
| 360 | 378 | all_s3_keys.push((key.clone(), crate::storage::S3Bucket::Main.as_str().to_string())); | |
| 361 | 379 | } | |
| 362 | 380 | } | |
| @@ -367,10 +385,11 @@ pub(super) async fn purge_expired_deleted_items(state: &AppState) { | |||
| 367 | 385 | ||
| 368 | 386 | // Gallery images (item_images) cascade away with the purged items; collect | |
| 369 | 387 | // their keys too or the objects orphan with no durable record (Run #18 B2). | |
| 388 | + | // CDN-served → public bucket post-promote (or staging in main); enqueue both. | |
| 370 | 389 | match db::gallery_images::s3_keys_for_expired_purged_items(&state.db).await { | |
| 371 | 390 | Ok(keys) => { | |
| 372 | 391 | for key in &keys { | |
| 373 | - | all_s3_keys.push((key.clone(), crate::storage::S3Bucket::Main.as_str().to_string())); | |
| 392 | + | all_s3_keys.extend(crate::storage::both_bucket_delete(key)); | |
| 374 | 393 | } | |
| 375 | 394 | } | |
| 376 | 395 | Err(e) => { | |
| @@ -535,6 +554,7 @@ pub(super) async fn cleanup_orphaned_uploads(state: &AppState) { | |||
| 535 | 554 | for (s3_key, bucket) in &stale { | |
| 536 | 555 | let s3_client = match crate::storage::S3Bucket::from_db_str(bucket) { | |
| 537 | 556 | crate::storage::S3Bucket::Synckit => state.synckit_s3.as_ref(), | |
| 557 | + | crate::storage::S3Bucket::Public => state.public_s3.as_ref(), | |
| 538 | 558 | crate::storage::S3Bucket::Main => state.s3.as_ref(), | |
| 539 | 559 | }; | |
| 540 | 560 | if let Some(s3) = s3_client { | |
| @@ -642,6 +662,7 @@ pub(super) async fn retry_pending_s3_deletions(state: &AppState) { | |||
| 642 | 662 | ||
| 643 | 663 | let s3 = match crate::storage::S3Bucket::from_db_str(&row.bucket) { | |
| 644 | 664 | crate::storage::S3Bucket::Synckit => state.synckit_s3.as_ref(), | |
| 665 | + | crate::storage::S3Bucket::Public => state.public_s3.as_ref(), | |
| 645 | 666 | crate::storage::S3Bucket::Main => state.s3.as_ref(), | |
| 646 | 667 | }; | |
| 647 | 668 | ||
| @@ -658,7 +679,9 @@ pub(super) async fn retry_pending_s3_deletions(state: &AppState) { | |||
| 658 | 679 | // synckit branch was previously unguarded (ultra-fuzz Run 11 Storage LOW). | |
| 659 | 680 | let live_owner: Option<String> = | |
| 660 | 681 | match crate::storage::S3Bucket::from_db_str(&row.bucket) { | |
| 661 | - | crate::storage::S3Bucket::Main => { | |
| 682 | + | // Both the main and public buckets key content under | |
| 683 | + | // `{user_id}/`, so the owner guard is identical. | |
| 684 | + | crate::storage::S3Bucket::Main | crate::storage::S3Bucket::Public => { | |
| 662 | 685 | match row | |
| 663 | 686 | .s3_key | |
| 664 | 687 | .strip_suffix('/') |
| @@ -331,6 +331,13 @@ impl S3DeleteAuthority { | |||
| 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 @@ impl S3Bucket { | |||
| 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 @@ impl S3Bucket { | |||
| 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 @@ pub trait StorageBackend: Send + Sync { | |||
| 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 @@ impl S3Client { | |||
| 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 @@ impl StorageBackend for S3Client { | |||
| 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 |
| @@ -308,6 +308,7 @@ impl TestHarness { | |||
| 308 | 308 | signing_secret: "test-signing-secret-for-integration-tests".to_string(), | |
| 309 | 309 | storage: None, | |
| 310 | 310 | synckit_storage: None, | |
| 311 | + | public_storage: None, | |
| 311 | 312 | stripe: None, | |
| 312 | 313 | admin_user_id: opts.admin_user_id, | |
| 313 | 314 | synckit_jwt_secret: Some("test-synckit-jwt-secret".to_string()), | |
| @@ -362,6 +363,9 @@ impl TestHarness { | |||
| 362 | 363 | let storage = opts.storage; | |
| 363 | 364 | let s3 = storage.clone().map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>); | |
| 364 | 365 | let synckit_s3 = opts.synckit_storage.map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>); | |
| 366 | + | // Public bucket shares the same in-memory backend as `s3` (one flat map, | |
| 367 | + | // no bucket isolation) so the cross-bucket image promote resolves. | |
| 368 | + | let public_s3 = s3.clone(); | |
| 365 | 369 | ||
| 366 | 370 | let state = AppState { | |
| 367 | 371 | db: pool.clone(), | |
| @@ -371,6 +375,7 @@ impl TestHarness { | |||
| 371 | 375 | runway_config: makenotwork::tier_prices::RunwayConfig::default(), | |
| 372 | 376 | s3, | |
| 373 | 377 | synckit_s3, | |
| 378 | + | public_s3, | |
| 374 | 379 | stripe: opts.stripe_client, | |
| 375 | 380 | email, | |
| 376 | 381 | docs: Arc::new(DocLoader::load(std::path::Path::new("."), &docengine::DocLoaderConfig { | |
| @@ -554,6 +559,8 @@ impl TestHarness { | |||
| 554 | 559 | cdn_base_url: None, | |
| 555 | 560 | // OTA artifacts scan from the SyncKit bucket; tests share one backend. | |
| 556 | 561 | synckit_s3: Some(deps.s3.clone()), | |
| 562 | + | // Public bucket shares the same backend; image promotes copy here. | |
| 563 | + | public_s3: Some(deps.s3.clone()), | |
| 557 | 564 | }; | |
| 558 | 565 | // Hard cap to avoid an infinite loop if a job re-enqueues itself. | |
| 559 | 566 | for _ in 0..256 { |
| @@ -107,6 +107,13 @@ impl StorageBackend for InMemoryStorage { | |||
| 107 | 107 | Ok(()) | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | + | async fn copy_object_from(&self, _src_bucket: &str, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { | |
| 111 | + | // The in-memory store is one flat map with no bucket isolation, so the | |
| 112 | + | // cross-bucket promote is just a same-map copy. Tests wire `public_s3` to | |
| 113 | + | // the same backend as `s3` so the staging source resolves here. | |
| 114 | + | self.copy_object(src_key, dst_key).await | |
| 115 | + | } | |
| 116 | + | ||
| 110 | 117 | async fn check_connectivity(&self) -> std::result::Result<(), String> { | |
| 111 | 118 | Ok(()) | |
| 112 | 119 | } |
| @@ -57,6 +57,7 @@ pub async fn run(config: LoadConfig) { | |||
| 57 | 57 | signing_secret: "load-test-signing-secret".to_string(), | |
| 58 | 58 | storage: None, | |
| 59 | 59 | synckit_storage: None, | |
| 60 | + | public_storage: None, | |
| 60 | 61 | stripe: None, | |
| 61 | 62 | admin_user_id: None, | |
| 62 | 63 | synckit_jwt_secret: None, | |
| @@ -110,6 +111,7 @@ pub async fn run(config: LoadConfig) { | |||
| 110 | 111 | runway_config: makenotwork::tier_prices::RunwayConfig::default(), | |
| 111 | 112 | s3: None, | |
| 112 | 113 | synckit_s3: None, | |
| 114 | + | public_s3: None, | |
| 113 | 115 | stripe: None, | |
| 114 | 116 | email, | |
| 115 | 117 | docs: Arc::new(DocLoader::load(std::path::Path::new("."), &docengine::DocLoaderConfig { |
| @@ -919,6 +919,8 @@ async fn is_s3_key_live_matches_project_cover_by_bare_key() { | |||
| 919 | 919 | // Migration 152: project covers store a bare `cover_s3_key` and liveness is | |
| 920 | 920 | // an exact key match (no URL-suffix matching). A project whose cover key is | |
| 921 | 921 | // set must report that key live, regardless of the cover_image_url value. | |
| 922 | + | // Covers are CDN-served, so their content object (and thus its liveness ref) | |
| 923 | + | // lives in the PUBLIC bucket post-promote (S1 public/private split). | |
| 922 | 924 | let mut h = TestHarness::new().await; | |
| 923 | 925 | let user_id = h.signup("coverliveuser", "coverlive@example.com", "Password1!").await; | |
| 924 | 926 | let pid: uuid::Uuid = sqlx::query_scalar( | |
| @@ -933,13 +935,13 @@ async fn is_s3_key_live_matches_project_cover_by_bare_key() { | |||
| 933 | 935 | let _ = pid; | |
| 934 | 936 | ||
| 935 | 937 | assert!( | |
| 936 | - | makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "main", "abc/projects/p/image/c.png") | |
| 938 | + | makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "public", "abc/projects/p/image/c.png") | |
| 937 | 939 | .await | |
| 938 | 940 | .unwrap(), | |
| 939 | - | "a project cover's bare s3_key must be reported live" | |
| 941 | + | "a project cover's bare s3_key must be reported live in the public bucket" | |
| 940 | 942 | ); | |
| 941 | 943 | assert!( | |
| 942 | - | !makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "main", "abc/projects/p/image/OTHER.png") | |
| 944 | + | !makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "public", "abc/projects/p/image/OTHER.png") | |
| 943 | 945 | .await | |
| 944 | 946 | .unwrap(), | |
| 945 | 947 | "a non-matching cover key must not be reported live" |
| @@ -222,14 +222,36 @@ impl S3Client { | |||
| 222 | 222 | /// `[A-Za-z0-9._/-]` (see the server's `sanitize_filename`), none of which | |
| 223 | 223 | /// require percent-encoding, so the source is formed directly. | |
| 224 | 224 | pub async fn copy_object(&self, src_key: &str, dst_key: &str) -> Result<(), String> { | |
| 225 | + | self.copy_object_from(&self.bucket, src_key, dst_key).await | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// Server-side copy from an arbitrary source bucket into THIS client's | |
| 229 | + | /// bucket. Used by the scan-then-promote path to lift a Clean object from | |
| 230 | + | /// the private staging bucket into the public (CDN-served) bucket in one | |
| 231 | + | /// server-side operation (no bytes transit the process). The credentials | |
| 232 | + | /// this client holds must have read on `src_bucket`; in this deployment all | |
| 233 | + | /// buckets share one Hetzner project/key, so cross-bucket copy is permitted. | |
| 234 | + | /// | |
| 235 | + | /// `CopySource` is `{src_bucket}/{src_key}`. Keys are sanitized to | |
| 236 | + | /// `[A-Za-z0-9._/-]` (server `sanitize_filename`), so no percent-encoding is | |
| 237 | + | /// needed and the source is formed directly. When `src_bucket` equals this | |
| 238 | + | /// client's bucket the copy is the ordinary same-bucket promote. | |
| 239 | + | pub async fn copy_object_from( | |
| 240 | + | &self, | |
| 241 | + | src_bucket: &str, | |
| 242 | + | src_key: &str, | |
| 243 | + | dst_key: &str, | |
| 244 | + | ) -> Result<(), String> { | |
| 225 | 245 | self.client | |
| 226 | 246 | .copy_object() | |
| 227 | 247 | .bucket(&self.bucket) | |
| 228 | - | .copy_source(format!("{}/{}", self.bucket, src_key)) | |
| 248 | + | .copy_source(format!("{src_bucket}/{src_key}")) | |
| 229 | 249 | .key(dst_key) | |
| 230 | 250 | .send() | |
| 231 | 251 | .await | |
| 232 | - | .map_err(|e| format!("S3 copy_object {src_key} -> {dst_key} failed: {e}"))?; | |
| 252 | + | .map_err(|e| { | |
| 253 | + | format!("S3 copy_object {src_bucket}/{src_key} -> {}/{dst_key} failed: {e}", self.bucket) | |
| 254 | + | })?; | |
| 233 | 255 | Ok(()) | |
| 234 | 256 | } | |
| 235 | 257 |