max / makenotwork
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; |