Skip to main content

max / makenotwork

audit Run 15 Phase 3: storage fail-open fixes - OTA re-upload resets scan_status to 'pending' in the ON CONFLICT DO UPDATE, so re-uploaded bytes to an existing release/target/arch key are re-scanned before artifact_download will serve them (was: stayed 'clean'). - CDN-served image kinds (item/project covers, gallery carousels, content-insertion clips) now carry a per-row scan_status (migration 162, default 'pending', existing rows backfilled 'clean'). The worker stamps the trust-overlaid verdict by s3_key (set_cdn_image_scan_status_by_key, symmetric with the quarantine purge); rendering is gated to 'clean' only, so pending AND held images are hidden until the scan clears (fail-closed, no data loss). Fan playback of insertions uses a clean-only resolver; creator management views and storage-accounting counts are unchanged. - clamav buffered scan path routes a mid-stream clamd disconnect (EPIPE on StreamMaxLength) to the same fail-closed incomplete-scan hold as the streaming path, instead of an Err that was mapped to fail-open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 00:58 UTC
Commit: 2389eaaabc453ada4a229c11348af5a73a007054
Parent: bf61ca7
18 files changed, +540 insertions, -46 deletions
@@ -0,0 +1,49 @@
1 + -- Fail-closed "held gate" for CDN-served images (audit 2026-07-01).
2 + --
3 + -- Four scan-target kinds render straight from cdn.makenot.work/{key} with no
4 + -- per-request gate and no scan_status column: item covers, project covers, the
5 + -- item/project gallery carousels, and content-insertion clips. Before this
6 + -- migration they rendered during the pre-verdict window, and a HeldForReview
7 + -- verdict left them serving indefinitely (only a hard Quarantined verdict
8 + -- removed them, by deleting the row). Add a per-row scan status, default
9 + -- 'pending', that the scan worker flips to 'clean' or 'held' keyed on s3_key;
10 + -- rendering is gated to 'clean' only, so 'pending' and 'held' rows stay hidden.
11 + --
12 + -- Existing rows predate the gate, so backfill them to 'clean' to keep every
13 + -- currently-serving image visible. New rows start 'pending' and only render
14 + -- once the pipeline clears them.
15 +
16 + ALTER TABLE item_images
17 + ADD COLUMN IF NOT EXISTS scan_status TEXT NOT NULL DEFAULT 'pending';
18 + ALTER TABLE project_images
19 + ADD COLUMN IF NOT EXISTS scan_status TEXT NOT NULL DEFAULT 'pending';
20 + ALTER TABLE content_insertions
21 + ADD COLUMN IF NOT EXISTS scan_status TEXT NOT NULL DEFAULT 'pending';
22 +
23 + ALTER TABLE items
24 + ADD COLUMN IF NOT EXISTS cover_scan_status TEXT NOT NULL DEFAULT 'pending';
25 + ALTER TABLE projects
26 + ADD COLUMN IF NOT EXISTS cover_scan_status TEXT NOT NULL DEFAULT 'pending';
27 +
28 + -- Backfill: every existing gallery / insertion row predates the gate, so mark
29 + -- the whole table 'clean' (nothing is mid-scan at migration time).
30 + UPDATE item_images SET scan_status = 'clean';
31 + UPDATE project_images SET scan_status = 'clean';
32 + UPDATE content_insertions SET scan_status = 'clean';
33 +
34 + -- Covers live on the items/projects row: only clear rows that actually have a
35 + -- cover. Rows with no cover keep 'pending' harmlessly (there is nothing to
36 + -- render, and a future cover upload re-enters the gate).
37 + UPDATE items
38 + SET cover_scan_status = 'clean'
39 + WHERE cover_s3_key IS NOT NULL OR cover_image_url IS NOT NULL;
40 + UPDATE projects
41 + SET cover_scan_status = 'clean'
42 + WHERE cover_s3_key IS NOT NULL OR cover_image_url IS NOT NULL;
43 +
44 + -- Partial indexes matching the render reads: the gallery lists select clean
45 + -- rows for a parent in display order.
46 + CREATE INDEX IF NOT EXISTS idx_item_images_item_clean
47 + ON item_images(item_id, position) WHERE scan_status = 'clean';
48 + CREATE INDEX IF NOT EXISTS idx_project_images_project_clean
49 + ON project_images(project_id, position) WHERE scan_status = 'clean';
@@ -165,6 +165,11 @@ pub async fn create_placement(
165 165
166 166 /// List all placements for an item, joined with insertion metadata.
167 167 /// Ordered by position (pre_roll, mid_roll by offset, post_roll) then sort_order.
168 + ///
169 + /// This is the creator's placement-management view (it lists every placement the
170 + /// creator configured, including ones whose clip is still pending/held), so it is
171 + /// NOT scan-gated. The fan-facing playback path uses
172 + /// [`list_playable_placements_for_item`], which hides un-cleared clips.
168 173 #[tracing::instrument(skip_all)]
169 174 pub async fn list_placements_for_item(
170 175 pool: &PgPool,
@@ -198,6 +203,44 @@ pub async fn list_placements_for_item(
198 203 Ok(rows)
199 204 }
200 205
206 + /// Fan-facing playback resolver: like [`list_placements_for_item`] but gated to
207 + /// insertions whose scan cleared (`i.scan_status = 'clean'`). A pending or held
208 + /// clip is never spliced into a fan's stream — the fail-closed gate for the
209 + /// gate-less, CDN-served insertion kind. Used only by the public media player
210 + /// (`build_segments_json`); creator management goes through the ungated variant.
211 + #[tracing::instrument(skip_all)]
212 + pub async fn list_playable_placements_for_item(
213 + pool: &PgPool,
214 + item_id: ItemId,
215 + ) -> Result<Vec<DbPlacementWithInsertion>> {
216 + let rows = sqlx::query_as::<_, DbPlacementWithInsertion>(
217 + r#"
218 + SELECT
219 + p.id, p.item_id, p.insertion_id, p.position, p.offset_ms, p.sort_order, p.created_at,
220 + i.title AS insertion_title,
221 + i.duration_ms AS insertion_duration_ms,
222 + i.storage_key AS insertion_storage_key
223 + FROM content_insertion_placements p
224 + JOIN content_insertions i ON i.id = p.insertion_id
225 + WHERE p.item_id = $1 AND i.scan_status = 'clean'
226 + ORDER BY
227 + CASE p.position
228 + WHEN 'pre_roll' THEN 0
229 + WHEN 'mid_roll' THEN 1
230 + WHEN 'post_roll' THEN 2
231 + END,
232 + p.offset_ms NULLS LAST,
233 + p.sort_order
234 + LIMIT 100
235 + "#,
236 + )
237 + .bind(item_id)
238 + .fetch_all(pool)
239 + .await?;
240 +
241 + Ok(rows)
242 + }
243 +
201 244 /// Delete a single placement by ID. Returns true if deleted.
202 245 #[tracing::instrument(skip_all)]
203 246 pub async fn delete_placement(
@@ -37,9 +37,13 @@ pub const MAX_GALLERY_IMAGES: i64 = 8;
37 37 /// List an item's gallery images in display order.
38 38 #[tracing::instrument(skip_all)]
39 39 pub async fn list_for_item<'e>(executor: impl PgExecutor<'e>, item_id: ItemId) -> Result<Vec<GalleryImage>> {
40 + // Fail-closed render gate: only images whose scan cleared are carouselled.
41 + // Pending/held rows stay hidden. The per-entity cap (`count_for_item`) still
42 + // counts every row, so a held image cannot be re-uploaded around the limit.
40 43 let rows = sqlx::query_as::<_, GalleryImage>(
41 44 "SELECT id, s3_key, image_url, alt, position, file_size_bytes \
42 - FROM item_images WHERE item_id = $1 ORDER BY position, created_at",
45 + FROM item_images WHERE item_id = $1 AND scan_status = 'clean' \
46 + ORDER BY position, created_at",
43 47 )
44 48 .bind(item_id)
45 49 .fetch_all(executor)
@@ -151,9 +155,12 @@ pub async fn reorder_item(pool: &PgPool, item_id: ItemId, ordered_ids: &[Uuid])
151 155 /// List a project's gallery images in display order.
152 156 #[tracing::instrument(skip_all)]
153 157 pub async fn list_for_project<'e>(executor: impl PgExecutor<'e>, project_id: ProjectId) -> Result<Vec<GalleryImage>> {
158 + // Fail-closed render gate: only cleared images are carouselled (see
159 + // `list_for_item`). The cap count (`count_for_project`) still counts all rows.
154 160 let rows = sqlx::query_as::<_, GalleryImage>(
155 161 "SELECT id, s3_key, image_url, alt, position, file_size_bytes \
156 - FROM project_images WHERE project_id = $1 ORDER BY position, created_at",
162 + FROM project_images WHERE project_id = $1 AND scan_status = 'clean' \
163 + ORDER BY position, created_at",
157 164 )
158 165 .bind(project_id)
159 166 .fetch_all(executor)
@@ -69,8 +69,13 @@ pub struct DbItem {
69 69 pub pwyw_enabled: bool,
70 70 /// Minimum price in cents when PWYW is enabled (floor).
71 71 pub pwyw_min_cents: Option<i32>,
72 - /// Malware scan status for uploaded files.
72 + /// Malware scan status for uploaded files (gates the audio/video track).
73 73 pub scan_status: super::super::FileScanStatus,
74 + /// Malware scan status for the cover image (`cover_s3_key`/`cover_image_url`),
75 + /// which renders straight from the CDN. `String` (not `FileScanStatus`) so a
76 + /// forward-compat DB value can't fail the row decode; the render gate treats
77 + /// anything other than `"clean"` as hidden. Default `'pending'`.
78 + pub cover_scan_status: String,
74 79 /// When a release announcement was sent (prevents re-announcement on unpublish/republish).
75 80 pub release_announced_at: Option<DateTime<Utc>>,
76 81 /// Scheduled publication time (item stays draft until this time, then the scheduler publishes it).
@@ -312,6 +317,7 @@ mod tests {
312 317 pwyw_enabled: false,
313 318 pwyw_min_cents: None,
314 319 scan_status: super::super::super::FileScanStatus::Pending,
320 + cover_scan_status: "pending".to_string(),
315 321 release_announced_at: None,
316 322 publish_at: None,
317 323 mt_thread_id: None,
@@ -24,6 +24,11 @@ pub struct DbProject {
24 24 pub project_type: super::super::ProjectType,
25 25 /// URL to the project's cover image.
26 26 pub cover_image_url: Option<String>,
27 + /// Malware scan status for the cover image (`cover_s3_key`/`cover_image_url`),
28 + /// which renders straight from the CDN. `String` (not `FileScanStatus`) so a
29 + /// forward-compat DB value can't fail the row decode; the render gate treats
30 + /// anything other than `"clean"` as hidden. Default `'pending'`.
31 + pub cover_scan_status: String,
27 32 /// Whether this project is publicly visible.
28 33 pub is_public: bool,
29 34 /// When the project was created.
@@ -99,6 +104,10 @@ pub struct DbProjectWithItemCount {
99 104 pub project_type: super::super::ProjectType,
100 105 /// URL to the project's cover image.
101 106 pub cover_image_url: Option<String>,
107 + /// Malware scan status for the cover image; the render gate hides anything
108 + /// other than `"clean"`. Selected explicitly by
109 + /// `get_public_projects_with_item_counts`.
110 + pub cover_scan_status: String,
102 111 /// Whether this project is publicly visible.
103 112 pub is_public: bool,
104 113 /// When the project was created.
@@ -204,7 +204,8 @@ pub async fn create_artifact(
204 204 INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size)
205 205 VALUES ($1, $2, $3, $4, $5)
206 206 ON CONFLICT (release_id, target, arch)
207 - DO UPDATE SET s3_key = EXCLUDED.s3_key, file_size = EXCLUDED.file_size
207 + DO UPDATE SET s3_key = EXCLUDED.s3_key, file_size = EXCLUDED.file_size,
208 + scan_status = 'pending'
208 209 RETURNING *
209 210 "#,
210 211 )
@@ -302,6 +302,7 @@ pub async fn get_public_projects_with_item_counts(
302 302 p.description,
303 303 p.project_type,
304 304 p.cover_image_url,
305 + p.cover_scan_status,
305 306 p.is_public,
306 307 p.created_at,
307 308 p.updated_at,
@@ -90,6 +90,45 @@ pub async fn purge_cdn_image_rows_by_key(db: &PgPool, s3_key: &str) -> Result<u6
90 90 Ok(removed)
91 91 }
92 92
93 + /// Set the per-row `scan_status` on every CDN-served image surface keyed by
94 + /// `s3_key`, in one transaction. The symmetric read-side counterpart of
95 + /// [`purge_cdn_image_rows_by_key`]: where the purge DELETEs (or NULLs) a
96 + /// quarantined reference, this stamps a non-quarantine terminal verdict —
97 + /// `clean` (renders) or `held` (stays hidden) — so the fail-closed gate can
98 + /// distinguish "not yet scanned" (`pending`) from "scanned and cleared".
99 + ///
100 + /// Touches the same surfaces as the purge: the gallery tables
101 + /// (`item_images.s3_key`, `project_images.s3_key`,
102 + /// `content_insertions.storage_key`) and the cover columns on `items` /
103 + /// `projects` (`cover_s3_key`). Wrapped in a single transaction so a mid-loop
104 + /// failure can't leave the reference set half-stamped (some surfaces cleared to
105 + /// `clean` while others stay `pending`). Returns total rows affected.
106 + #[tracing::instrument(skip_all)]
107 + pub async fn set_cdn_image_scan_status_by_key(
108 + db: &PgPool,
109 + s3_key: &str,
110 + status: FileScanStatus,
111 + ) -> Result<u64, sqlx::Error> {
112 + let mut tx = db.begin().await?;
113 + let mut affected = 0u64;
114 + for sql in [
115 + "UPDATE item_images SET scan_status = $1 WHERE s3_key = $2",
116 + "UPDATE project_images SET scan_status = $1 WHERE s3_key = $2",
117 + "UPDATE content_insertions SET scan_status = $1 WHERE storage_key = $2",
118 + "UPDATE items SET cover_scan_status = $1 WHERE cover_s3_key = $2",
119 + "UPDATE projects SET cover_scan_status = $1 WHERE cover_s3_key = $2",
120 + ] {
121 + affected += sqlx::query(sql)
122 + .bind(status)
123 + .bind(s3_key)
124 + .execute(&mut *tx)
125 + .await?
126 + .rows_affected();
127 + }
128 + tx.commit().await?;
129 + Ok(affected)
130 + }
131 +
93 132 pub async fn insert_scan_result(
94 133 db: &PgPool,
95 134 s3_key: &str,
@@ -953,6 +953,7 @@ mod tests {
953 953 pwyw_enabled,
954 954 pwyw_min_cents,
955 955 scan_status: db::FileScanStatus::Clean,
956 + cover_scan_status: "clean".to_string(),
956 957 release_announced_at: None,
957 958 publish_at: None,
958 959 mt_thread_id: None,
@@ -990,6 +991,7 @@ mod tests {
990 991 description: None,
991 992 project_type: db::ProjectType::General,
992 993 cover_image_url: None,
994 + cover_scan_status: "clean".to_string(),
993 995 theme_id: None,
994 996 is_public: true,
995 997 created_at: chrono::Utc::now(),
@@ -206,8 +206,10 @@ pub(super) async fn confirm_insertion(
206 206 .await?;
207 207
208 208 // Scan AFTER the insertion row is created — same ordering rule that the
209 - // storage handlers follow. No per-row scan_status column for insertions;
210 - // worker logs + creates a WAM ticket on quarantine for admin follow-up.
209 + // storage handlers follow. The row starts scan_status='pending' (fail-closed
210 + // gate): fan playback (list_playable_placements_for_item) hides it until the
211 + // worker flips it to 'clean'; on quarantine the row is purged and a WAM ticket
212 + // filed. The creator still sees the pending clip in their management library.
211 213 commit_upload(
212 214 &state,
213 215 CommitTarget::ContentInsertion(insertion.id),
@@ -315,7 +315,7 @@ pub(super) async fn build_segments_json(
315 315 db_item: &db::DbItem,
316 316 ) -> String {
317 317 let placements =
318 - match db::content_insertions::list_placements_for_item(&state.db, item_id).await {
318 + match db::content_insertions::list_playable_placements_for_item(&state.db, item_id).await {
319 319 Ok(p) => p,
320 320 Err(_) => return "null".to_string(),
321 321 };
@@ -181,22 +181,24 @@ pub(crate) enum CommitTarget {
181 181 Version(db::VersionId),
182 182 /// A media library file (`media_files` table).
183 183 Media(db::MediaFileId),
184 - /// A project cover image (URL stored on `projects.cover_image_url`;
185 - /// project images do not carry a per-row scan_status column — the worker
186 - /// only logs and creates a WAM ticket on quarantine).
184 + /// A project cover image (`projects.cover_image_url`/`cover_s3_key`). Gated
185 + /// by the CDN-image `cover_scan_status` column (migration 162): the worker
186 + /// stamps it clean/held keyed on s3_key, and on quarantine purges the row.
187 187 ProjectImage(db::ProjectId),
188 - /// An item cover image (`items.cover_s3_key`/`cover_image_url`). Like
189 - /// `ProjectImage`/`GalleryImage` it carries NO per-row scan_status: the
190 - /// cover is CDN-served with no per-request gate, and `items.scan_status`
191 - /// gates the *audio/video*, not the cover. The worker logs + tickets on
192 - /// quarantine and NULLs the cover columns.
188 + /// An item cover image (`items.cover_s3_key`/`cover_image_url`), CDN-served
189 + /// with no per-request gate. It is gated by the separate `cover_scan_status`
190 + /// column (migration 162), NOT `items.scan_status` (which gates the
191 + /// audio/video track — flipping that would take the published track offline).
192 + /// The worker stamps `cover_scan_status` clean/held and, on quarantine, NULLs
193 + /// the cover columns.
193 194 ItemImage(db::ItemId),
194 - /// A gallery image row (`item_images`/`project_images`); like ProjectImage,
195 - /// no per-row scan_status column — the worker logs + tickets on quarantine.
196 - /// Carries the gallery row's own id (for log correlation only; unused by the
197 - /// worker, which acts on the s3_key).
195 + /// A gallery image row (`item_images`/`project_images`), gated by the row's
196 + /// `scan_status` column (migration 162). The worker stamps it clean/held by
197 + /// s3_key and purges on quarantine. Carries the gallery row's own id (for log
198 + /// correlation only; unused by the worker, which acts on the s3_key).
198 199 GalleryImage(Uuid),
199 - /// A content insertion clip; no per-row scan_status column.
200 + /// A content insertion clip (`content_insertions`), gated by its
201 + /// `scan_status` column (migration 162); worker stamps clean/held by key.
200 202 ContentInsertion(db::ContentInsertionId),
201 203 }
202 204
@@ -279,13 +281,15 @@ pub(crate) async fn commit_upload(
279 281 | CommitTarget::ProjectImage(_)
280 282 | CommitTarget::GalleryImage(_)
281 283 | CommitTarget::ContentInsertion(_) => {
282 - // No per-row scan_status column to flip. `ItemImage` writes the item
283 - // *cover* (`items.cover_s3_key`/`cover_image_url`, CDN-served with no
284 - // per-request gate), NOT the audio/video that `items.scan_status`
285 - // gates — flipping that column would take the already-published track
286 - // offline for every fan until the cover re-scan finished (Run #20
287 - // Storage SERIOUS). The worker logs + creates a WAM ticket and, on
288 - // quarantine, NULLs the cover columns; the track stays online.
284 + // Nothing to flip here at commit time: these CDN-served image kinds
285 + // carry their own per-row scan gate (`cover_scan_status` on
286 + // items/projects; `scan_status` on item_images/project_images/
287 + // content_insertions, migration 162), which defaults to 'pending' so
288 + // the row stays hidden until scanned. The worker stamps it clean/held
289 + // keyed on s3_key when the scan completes (fail-closed held gate), and
290 + // on quarantine purges the row / NULLs the cover columns. We must NOT
291 + // flip `items.scan_status` here — that gates the audio/video track, and
292 + // a cover re-scan would take the published track offline (Run #20).
289 293 }
290 294 }
291 295 tracing::Span::current().record("scan_status", tracing::field::debug(&scan_status));
@@ -109,24 +109,23 @@ async fn scan_instream(socket_path: &str, data: &[u8]) -> Result<LayerResult, St
109 109 .await
110 110 .map_err(|e| format!("Failed to send INSTREAM command: {}", e))?;
111 111
112 - // Send data in chunks: each chunk prefixed with 4-byte big-endian length
112 + // Send data in chunks: each chunk prefixed with 4-byte big-endian length.
113 + // If clamd drops the connection mid-stream (e.g. it hit StreamMaxLength), a
114 + // write fails with EPIPE. Route that to the same fail-closed incomplete-scan
115 + // hold the streaming path uses (`incomplete_after_clamd_dropped`) instead of
116 + // returning an Err — an Err here was mapped to a clamav "degraded" error and
117 + // then fail-OPEN, the opposite posture from the streaming twin (Run 15).
113 118 for chunk in data.chunks(CHUNK_SIZE) {
114 119 let len = (chunk.len() as u32).to_be_bytes();
115 - stream
116 - .write_all(&len)
117 - .await
118 - .map_err(|e| format!("Failed to write chunk length: {}", e))?;
119 - stream
120 - .write_all(chunk)
121 - .await
122 - .map_err(|e| format!("Failed to write chunk data: {}", e))?;
120 + if stream.write_all(&len).await.is_err() || stream.write_all(chunk).await.is_err() {
121 + return Ok(incomplete_after_clamd_dropped(&mut stream).await);
122 + }
123 123 }
124 124
125 125 // Send zero-length chunk to signal end of data
126 - stream
127 - .write_all(&0u32.to_be_bytes())
128 - .await
129 - .map_err(|e| format!("Failed to send end marker: {}", e))?;
126 + if stream.write_all(&0u32.to_be_bytes()).await.is_err() {
127 + return Ok(incomplete_after_clamd_dropped(&mut stream).await);
128 + }
130 129
131 130 // Read response (capped at 16 KB — verdicts are typically under 100 bytes)
132 131 let mut response = Vec::with_capacity(256);
@@ -481,6 +481,30 @@ async fn run_pipeline_and_decide(
481 481 }
482 482 }
483 483
484 + // Gate-less CDN-served image kinds (item/project covers, gallery carousels,
485 + // content-insertion clips) carry no entity `scan_status` column for
486 + // `update_entity_status` to flip, and they render straight from
487 + // cdn.makenot.work/{key} with no per-request gate. Stamp their per-row
488 + // scan_status here, keyed on s3_key (symmetric with the quarantine purge
489 + // above), so the fail-closed render gate can distinguish an unscanned
490 + // `pending` row from a cleared `clean` one. We stamp the trust-overlaid
491 + // `status`, not the raw pipeline `result.status`: an untrusted (or held)
492 + // upload's image stays hidden until an admin clears it — the same
493 + // fail-closed posture the gated Item/Version/Media kinds already get.
494 + // Quarantine never reaches here (it returned above with the row purged).
495 + if kind.is_cdn_served_without_gate() {
496 + match db::scanning::set_cdn_image_scan_status_by_key(&ctx.db, &job.s3_key, status).await {
497 + Ok(n) => tracing::info!(
498 + s3_key = %job.s3_key, target_kind = %kind.as_str(), scan_status = %status, rows = n,
499 + "stamped CDN-served image scan_status (renders only when clean)"
500 + ),
501 + Err(e) => tracing::warn!(
502 + s3_key = %job.s3_key, target_kind = %kind.as_str(), error = %e,
503 + "failed to stamp CDN-served image scan_status; image stays hidden until re-scan"
504 + ),
505 + }
506 + }
507 +
484 508 Ok(status)
485 509 }
486 510
@@ -278,7 +278,14 @@ impl Item {
278 278 sales_count: db_item.sales_count as u32,
279 279 tags: tags.iter().map(TagView::from).collect(),
280 280 content: ItemContent::Other,
281 - cover_image_url: db_item.cover_image_url.clone(),
281 + // Fail-closed cover gate: render the CDN-served cover only once its
282 + // scan cleared. Pending/held covers are hidden everywhere this view
283 + // is used, OG cards included.
284 + cover_image_url: if db_item.cover_scan_status == "clean" {
285 + db_item.cover_image_url.clone()
286 + } else {
287 + None
288 + },
282 289 is_free,
283 290 can_access,
284 291 enable_license_keys: db_item.enable_license_keys,
@@ -318,7 +325,11 @@ impl Item {
318 325 ItemContent::Audio {
319 326 duration,
320 327 duration_seconds: db_item.duration_seconds,
321 - cover_url: db_item.cover_image_url.clone(),
328 + cover_url: if db_item.cover_scan_status == "clean" {
329 + db_item.cover_image_url.clone()
330 + } else {
331 + None
332 + },
322 333 episode_number: db_item.episode_number.map(|n| n as u32),
323 334 audio_s3_key: db_item.audio_s3_key.clone(),
324 335 }
@@ -328,7 +339,11 @@ impl Item {
328 339 ItemContent::Video {
329 340 duration,
330 341 duration_seconds: db_item.video_duration_seconds,
331 - cover_url: db_item.cover_image_url.clone(),
342 + cover_url: if db_item.cover_scan_status == "clean" {
343 + db_item.cover_image_url.clone()
344 + } else {
345 + None
346 + },
332 347 video_s3_key: db_item.video_s3_key.clone(),
333 348 width: db_item.video_width,
334 349 height: db_item.video_height,
@@ -349,7 +364,11 @@ impl Item {
349 364 sales_count: db_item.sales_count as u32,
350 365 tags: tags.iter().map(TagView::from).collect(),
351 366 content,
352 - cover_image_url: db_item.cover_image_url.clone(),
367 + cover_image_url: if db_item.cover_scan_status == "clean" {
368 + db_item.cover_image_url.clone()
369 + } else {
370 + None
371 + },
353 372 is_free,
354 373 can_access,
355 374 enable_license_keys: db_item.enable_license_keys,
@@ -379,7 +398,12 @@ impl Project {
379 398 description: db_project.description.clone().unwrap_or_default(),
380 399 item_count,
381 400 project_type: db_project.project_type.to_string(),
382 - cover_image_url: db_project.cover_image_url.clone(),
401 + // Fail-closed cover gate: hide the CDN-served cover until its scan clears.
402 + cover_image_url: if db_project.cover_scan_status == "clean" {
403 + db_project.cover_image_url.clone()
404 + } else {
405 + None
406 + },
383 407 }
384 408 }
385 409 }
@@ -395,7 +419,12 @@ impl From<&db::DbProjectWithItemCount> for Project {
395 419 description: p.description.clone().unwrap_or_default(),
396 420 item_count: p.item_count.clamp(0, u32::MAX as i64) as u32,
397 421 project_type: p.project_type.to_string(),
398 - cover_image_url: p.cover_image_url.clone(),
422 + // Fail-closed cover gate: hide the CDN-served cover until its scan clears.
423 + cover_image_url: if p.cover_scan_status == "clean" {
424 + p.cover_image_url.clone()
425 + } else {
426 + None
427 + },
399 428 }
400 429 }
401 430 }
@@ -0,0 +1,264 @@
1 + //! Fail-closed "held gate" for CDN-served images (migration 162).
2 + //!
3 + //! Item/project covers, gallery carousels, and content-insertion clips render
4 + //! straight from the CDN with no per-request gate, so a per-row scan_status now
5 + //! decides visibility: `pending` and `held` must not render, only `clean` does.
6 + //! Quarantine (row purge) is exercised in `scanning.rs` and is unchanged here.
7 +
8 + use crate::harness::TestHarness;
9 + use makenotwork::db::{ItemId, ProjectId};
10 + use makenotwork::types::{Item, Project};
11 + use serde_json::{json, Value};
12 +
13 + async fn creator_with_item(h: &mut TestHarness) -> (String, String, String) {
14 + let setup = h.create_creator_with_item("gatecreator", "audio", 0).await;
15 + h.trust_user(setup.user_id).await;
16 + h.grant_tier(setup.user_id, "small_files").await;
17 + (setup.user_id.to_string(), setup.project_id, setup.item_id)
18 + }
19 +
20 + fn item_id(s: &str) -> ItemId {
21 + ItemId::from(uuid::Uuid::parse_str(s).unwrap())
22 + }
23 +
24 + fn project_id(s: &str) -> ProjectId {
25 + ProjectId::from(uuid::Uuid::parse_str(s).unwrap())
26 + }
27 +
28 + /// Point an item at a cover and stamp its cover_scan_status.
29 + async fn set_item_cover(h: &TestHarness, id: &str, status: &str) {
30 + sqlx::query(
31 + "UPDATE items SET cover_image_url = 'https://cdn.makenot.work/cover.png', \
32 + cover_s3_key = 'cover.png', cover_scan_status = $2 WHERE id = $1::uuid",
33 + )
34 + .bind(id)
35 + .bind(status)
36 + .execute(&h.db)
37 + .await
38 + .unwrap();
39 + }
40 +
41 + /// Point a project at a cover and stamp its cover_scan_status.
42 + async fn set_project_cover(h: &TestHarness, id: &str, status: &str) {
43 + sqlx::query(
44 + "UPDATE projects SET cover_image_url = 'https://cdn.makenot.work/pcover.png', \
45 + cover_s3_key = 'pcover.png', cover_scan_status = $2 WHERE id = $1::uuid",
46 + )
47 + .bind(id)
48 + .bind(status)
49 + .execute(&h.db)
50 + .await
51 + .unwrap();
52 + }
53 +
54 + async fn item_view(h: &TestHarness, id: &str) -> Item {
55 + let db_item = makenotwork::db::items::get_item_by_id(&h.db, item_id(id))
56 + .await
57 + .unwrap()
58 + .expect("item exists");
59 + Item::from_db_list(&db_item, &[], true, true)
60 + }
61 +
62 + async fn project_view(h: &TestHarness, id: &str) -> Project {
63 + let db_project = makenotwork::db::projects::get_project_by_id(&h.db, project_id(id))
64 + .await
65 + .unwrap()
66 + .expect("project exists");
67 + Project::from_db(&db_project, 0)
68 + }
69 +
70 + // ---------------------------------------------------------------------------
71 + // Covers (items + projects)
72 + // ---------------------------------------------------------------------------
73 +
74 + #[tokio::test]
75 + async fn pending_cover_hidden_from_item_view() {
76 + let mut h = TestHarness::with_storage().await;
77 + let (_, _, iid) = creator_with_item(&mut h).await;
78 + set_item_cover(&h, &iid, "pending").await;
79 + assert_eq!(
80 + item_view(&h, &iid).await.cover_image_url,
81 + None,
82 + "a pending (unscanned) cover must not render"
83 + );
84 + }
85 +
86 + #[tokio::test]
87 + async fn held_cover_hidden_from_item_view() {
88 + let mut h = TestHarness::with_storage().await;
89 + let (_, _, iid) = creator_with_item(&mut h).await;
90 + set_item_cover(&h, &iid, "held_for_review").await;
91 + assert_eq!(
92 + item_view(&h, &iid).await.cover_image_url,
93 + None,
94 + "a held cover must not render"
95 + );
96 + }
97 +
98 + #[tokio::test]
99 + async fn clean_cover_renders_in_item_view() {
100 + let mut h = TestHarness::with_storage().await;
101 + let (_, _, iid) = creator_with_item(&mut h).await;
102 + set_item_cover(&h, &iid, "clean").await;
103 + assert_eq!(
104 + item_view(&h, &iid).await.cover_image_url.as_deref(),
105 + Some("https://cdn.makenot.work/cover.png"),
106 + "a clean cover must render"
107 + );
108 + }
109 +
110 + #[tokio::test]
111 + async fn held_project_cover_image_hidden_clean_renders() {
112 + let mut h = TestHarness::with_storage().await;
113 + let (_, pid, _) = creator_with_item(&mut h).await;
114 +
115 + set_project_cover(&h, &pid, "held_for_review").await;
116 + assert_eq!(
117 + project_view(&h, &pid).await.cover_image_url,
118 + None,
119 + "a held project cover must not render"
120 + );
121 +
122 + set_project_cover(&h, &pid, "clean").await;
123 + assert_eq!(
124 + project_view(&h, &pid).await.cover_image_url.as_deref(),
125 + Some("https://cdn.makenot.work/pcover.png"),
126 + "a clean project cover must render"
127 + );
128 + }
129 +
130 + // ---------------------------------------------------------------------------
131 + // Gallery carousel
132 + // ---------------------------------------------------------------------------
133 +
134 + /// Insert a gallery image row directly with a chosen scan_status.
135 + async fn insert_gallery_image(h: &TestHarness, item: &str, key: &str, pos: i32, status: &str) {
136 + sqlx::query(
137 + "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes, scan_status) \
138 + VALUES ($1::uuid, $2, $3, 'caption', $4, 10, $5)",
139 + )
140 + .bind(item)
141 + .bind(key)
142 + .bind(format!("https://cdn.makenot.work/{key}"))
143 + .bind(pos)
144 + .bind(status)
145 + .execute(&h.db)
146 + .await
147 + .unwrap();
148 + }
149 +
150 + #[tokio::test]
151 + async fn held_gallery_image_excluded_from_list_for_item() {
152 + let mut h = TestHarness::with_storage().await;
153 + let (_, _, iid) = creator_with_item(&mut h).await;
154 +
155 + insert_gallery_image(&h, &iid, "clean.png", 0, "clean").await;
156 + insert_gallery_image(&h, &iid, "held.png", 1, "held_for_review").await;
157 + insert_gallery_image(&h, &iid, "pending.png", 2, "pending").await;
158 +
159 + let listed = makenotwork::db::gallery_images::list_for_item(&h.db, item_id(&iid))
160 + .await
161 + .unwrap();
162 + let keys: Vec<&str> = listed.iter().map(|g| g.s3_key.as_str()).collect();
163 + assert_eq!(keys, vec!["clean.png"], "only the clean gallery image renders");
164 +
165 + // The per-entity cap still counts every row (held/pending included), so a
166 + // held image cannot be re-uploaded around the limit.
167 + let cap_count = makenotwork::db::gallery_images::count_for_item(&h.db, item_id(&iid))
168 + .await
169 + .unwrap();
170 + assert_eq!(cap_count, 3, "the cap count includes non-clean rows");
171 + }
172 +
173 + // ---------------------------------------------------------------------------
174 + // Content insertions (fan playback vs creator management)
175 + // ---------------------------------------------------------------------------
176 +
177 + /// Presign + upload + confirm one insertion clip for the logged-in creator, then
178 + /// place it as a pre-roll on `item_id`. Returns the insertion's id + storage key.
179 + async fn add_placed_insertion(h: &mut TestHarness, item: &str, title: &str) -> (String, String) {
180 + let resp = h
181 + .client
182 + .post_json(
183 + "/api/users/me/insertions/presign",
184 + &json!({ "file_name": "clip.mp3", "content_type": "audio/mpeg" }).to_string(),
185 + )
186 + .await;
187 + assert!(resp.status.is_success(), "insertion presign failed: {}", resp.text);
188 + let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
189 +
190 + h.storage.as_ref().unwrap().put(&s3_key, b"fake clip".to_vec());
191 +
192 + let resp = h
193 + .client
194 + .post_json(
195 + "/api/users/me/insertions/confirm",
196 + &json!({
197 + "s3_key": s3_key,
198 + "title": title,
199 + "duration_ms": 5000,
200 + "file_size": 9,
201 + "mime_type": "audio/mpeg",
202 + })
203 + .to_string(),
204 + )
205 + .await;
206 + assert!(resp.status.is_success(), "insertion confirm failed: {}", resp.text);
207 + let insertion_id = resp.json::<Value>()["id"].as_str().unwrap().to_string();
208 +
209 + let resp = h
210 + .client
211 + .post_json(
212 + &format!("/api/items/{item}/insertions"),
213 + &json!({ "insertion_id": insertion_id, "position": "pre_roll" }).to_string(),
214 + )
215 + .await;
216 + assert!(resp.status.is_success(), "placement failed: {}", resp.text);
217 +
218 + (insertion_id, s3_key)
219 + }
220 +
221 + async fn set_insertion_scan_status(h: &TestHarness, id: &str, status: &str) {
222 + sqlx::query("UPDATE content_insertions SET scan_status = $2 WHERE id = $1::uuid")
223 + .bind(id)
224 + .bind(status)
225 + .execute(&h.db)
226 + .await
227 + .unwrap();
228 + }
229 +
230 + #[tokio::test]
231 + async fn held_insertion_not_served_to_fans_but_visible_to_creator() {
232 + let mut h = TestHarness::with_storage().await;
233 + let (_, pid, iid) = creator_with_item(&mut h).await;
234 + h.publish_project_and_item(&pid, &iid).await;
235 +
236 + let title = "SponsorClipXYZ";
237 + let (ins_id, _key) = add_placed_insertion(&mut h, &iid, title).await;
238 +
239 + // Held: hidden from the fan playback path (library page segments)...
240 + set_insertion_scan_status(&h, &ins_id, "held_for_review").await;
241 + let fan = h.client.get(&format!("/l/{iid}")).await;
242 + assert!(fan.status.is_success(), "library page failed: {}", fan.text);
243 + assert!(
244 + !fan.text.contains(title),
245 + "a held insertion must not be spliced into fan playback"
246 + );
247 +
248 + // ...but still present in the creator's management library (ungated).
249 + let manage = h.client.get("/api/users/me/insertions").await;
250 + assert!(manage.status.is_success(), "manage list failed: {}", manage.text);
251 + assert!(
252 + manage.text.contains(title),
253 + "the creator must still see their held clip to manage it"
254 + );
255 +
256 + // Clean: now served to fans.
257 + set_insertion_scan_status(&h, &ins_id, "clean").await;
258 + let fan = h.client.get(&format!("/l/{iid}")).await;
259 + assert!(fan.status.is_success(), "library page failed: {}", fan.text);
260 + assert!(
261 + fan.text.contains(title),
262 + "a clean insertion must be served to fans"
263 + );
264 + }
@@ -65,6 +65,20 @@ async fn add_gallery_image(
65 65 assert!(resp.status.is_success(), "gallery confirm failed: {}", resp.text);
66 66 let data: Value = resp.json();
67 67 assert_eq!(data["success"], true);
68 +
69 + // A confirmed gallery image starts scan_status='pending' (fail-closed gate,
70 + // migration 162) and only renders once the scan worker flips it to 'clean'.
71 + // These tests run without a scan worker, so mark the row clean by key to
72 + // simulate a completed scan — the render reads (`list_for_item`) are gated to
73 + // clean, and these tests assert ordering of *visible* images.
74 + for table in ["item_images", "project_images"] {
75 + sqlx::query(&format!("UPDATE {table} SET scan_status = 'clean' WHERE s3_key = $1"))
76 + .bind(&s3_key)
77 + .execute(&h.db)
78 + .await
79 + .unwrap();
80 + }
81 +
68 82 (data["id"].as_str().unwrap().to_string(), s3_key)
69 83 }
70 84
@@ -29,6 +29,7 @@ mod promo_codes_free_access;
29 29 mod exports;
30 30 mod custom_links;
31 31 mod invites;
32 + mod cdn_scan_gate;
32 33 mod gallery;
33 34 mod storage;
34 35 mod stripe_webhooks;