max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
18 files changed,
+540 insertions,
-46 deletions
| @@ -953,6 +953,7 @@ | |||
| 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 @@ | |||
| 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(), |
| @@ -165,6 +165,11 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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) |
| @@ -204,7 +204,8 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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, |
| @@ -109,24 +109,23 @@ | |||
| 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); |