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
Signed with PGP, not checked
Commit: 5bec81b1f3d36c3f2b7826dfaf05f17d238ace75
Parent: acf5107
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