Skip to main content

max / makenotwork

server: split public image content into a separate CDN bucket (S1) Close the paid-content confidentiality gap: after C1 every object promotes to one key shape, so paid audio/video and public covers were destined for the same bucket with no edge-distinguishable boundary. Route by immutable public-ness instead. - New S3Bucket::Public + public_s3 backend (S3_PUBLIC_BUCKET, reuses the main endpoint/creds; required in production alongside CDN_BASE_URL). Cross-bucket copy_object_from in s3-storage lifts a Clean staging object into it. - promote_staging_to_content copies the three CDN-served image kinds (item/project cover, gallery) cross-bucket into the public bucket via the new content_served_from_public_bucket() predicate; all else stays private. ContentInsertion is served presigned, so it stays in the private bucket. - resolve_content_url always presigns downloadable media, even when free — the private media bucket is never served unsigned. Also closes free-content revocation (presigned URLs self-expire; nothing permanent to purge). - Deletion accounting: S3_KEY_REFS moves the four image columns to the public bucket; both_bucket_delete() covers keys of unknown promote state (cover replace, gallery delete, project-delete/purge cascades); account deletion sweeps the {user}/ prefix in the public bucket; both reaper resolutions and the prefix owner-guard handle the public bucket. Staging stays private — a Public staging bucket is now an invariant error. Deploy still needs the makenotwork-public bucket + public-read policy, the cdn.makenot.work Caddy vhost fix, and CDN_BASE_URL/S3_PUBLIC_BUCKET env. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 20:49 UTC
Commit: 5bec81b1f3d36c3f2b7826dfaf05f17d238ace75
Parent: acf5107
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