max / makenotwork
21 files changed,
+758 insertions,
-142 deletions
| @@ -60,6 +60,32 @@ pub async fn remove_pending_upload<'e>( | |||
| 60 | 60 | Ok(()) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | /// Whether `s3_key` (in `bucket`) is a pending upload this user presigned. | |
| 64 | + | /// | |
| 65 | + | /// The confirm handlers used to prove ownership by checking the client-supplied | |
| 66 | + | /// key started with the user's `{user_id}/{item_id}/…` prefix. With scan-then- | |
| 67 | + | /// promote the presigned key is an owner-less `staging/{uuid}.{ext}`, so that | |
| 68 | + | /// structural check no longer binds the key to a user. Every presign records the | |
| 69 | + | /// key here against its owner (`record_pending_upload`); confirm now proves | |
| 70 | + | /// ownership by looking it up — a caller cannot confirm a staging key it did not | |
| 71 | + | /// presign (and cannot guess another user's random staging uuid). | |
| 72 | + | pub async fn is_owned( | |
| 73 | + | pool: &PgPool, | |
| 74 | + | user_id: UserId, | |
| 75 | + | s3_key: &str, | |
| 76 | + | bucket: &str, | |
| 77 | + | ) -> Result<bool> { | |
| 78 | + | let owned = sqlx::query_scalar::<_, bool>( | |
| 79 | + | "SELECT EXISTS (SELECT 1 FROM pending_uploads WHERE s3_key = $1 AND user_id = $2 AND bucket = $3)", | |
| 80 | + | ) | |
| 81 | + | .bind(s3_key) | |
| 82 | + | .bind(user_id) | |
| 83 | + | .bind(bucket) | |
| 84 | + | .fetch_one(pool) | |
| 85 | + | .await?; | |
| 86 | + | Ok(owned) | |
| 87 | + | } | |
| 88 | + | ||
| 63 | 89 | /// Per-tick cap on the orphan-upload reaper. The reaper runs every scheduler | |
| 64 | 90 | /// tick under the tick-wide advisory lock and deletes serially (one S3 round- | |
| 65 | 91 | /// trip per row), so an unbounded result set lets a backlog wedge the tick |
| @@ -240,6 +240,124 @@ pub async fn update_media_file_scan_status( | |||
| 240 | 240 | Ok(()) | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | + | /// Most recent content hash recorded for a key, from the scan-result audit | |
| 244 | + | /// trail. The scan worker computes and stores the sha256 in `file_scan_results` | |
| 245 | + | /// at scan time; the admin-approve promote path (which flips a held file to | |
| 246 | + | /// Clean and must copy it to its content-addressed key) has only the staging | |
| 247 | + | /// key in scope, so it reads the hash back here. Empty hashes — recorded by a | |
| 248 | + | /// degraded/held scan that never fully hashed the object — are skipped, so a | |
| 249 | + | /// content key is never derived from a blank digest. | |
| 250 | + | #[tracing::instrument(skip_all)] | |
| 251 | + | pub async fn latest_sha256_by_key( | |
| 252 | + | db: &PgPool, | |
| 253 | + | s3_key: &str, | |
| 254 | + | ) -> Result<Option<String>, sqlx::Error> { | |
| 255 | + | sqlx::query_scalar::<_, String>( | |
| 256 | + | "SELECT sha256 FROM file_scan_results \ | |
| 257 | + | WHERE s3_key = $1 AND sha256 <> '' \ | |
| 258 | + | ORDER BY scanned_at DESC LIMIT 1", | |
| 259 | + | ) | |
| 260 | + | .bind(s3_key) | |
| 261 | + | .fetch_optional(db) | |
| 262 | + | .await | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// Repoint a GATED entity (Item audio/video, Version, Media, OTA artifact) from | |
| 266 | + | /// its staging key to the immutable content key and mark it Clean in one write. | |
| 267 | + | /// | |
| 268 | + | /// This is the scan-then-promote closing move for the kinds that carry their own | |
| 269 | + | /// `scan_status` gate. The `(table, key column)` pair is a compile-time constant | |
| 270 | + | /// selected by `(kind, file_type)` — never user input — so formatting it into the | |
| 271 | + | /// statement is safe. Uses the runtime `query` (not the `!` macro) so extending | |
| 272 | + | /// the promote set needs no offline-cache regeneration. Accepts any executor so | |
| 273 | + | /// the caller can run it inside the same transaction as the staging-key delete | |
| 274 | + | /// enqueue. | |
| 275 | + | pub async fn promote_gated<'e>( | |
| 276 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 277 | + | kind: crate::db::scan_jobs::ScanTargetKind, | |
| 278 | + | file_type: crate::storage::FileType, | |
| 279 | + | target_id: Uuid, | |
| 280 | + | content_key: &str, | |
| 281 | + | ) -> Result<(), sqlx::Error> { | |
| 282 | + | use crate::db::scan_jobs::ScanTargetKind as K; | |
| 283 | + | use crate::storage::FileType as F; | |
| 284 | + | let sql: &'static str = match (kind, file_type) { | |
| 285 | + | (K::Item, F::Audio) => { | |
| 286 | + | "UPDATE items SET audio_s3_key = $1, scan_status = 'clean', updated_at = NOW() WHERE id = $2" | |
| 287 | + | } | |
| 288 | + | (K::Item, F::Video) => { | |
| 289 | + | "UPDATE items SET video_s3_key = $1, scan_status = 'clean', updated_at = NOW() WHERE id = $2" | |
| 290 | + | } | |
| 291 | + | (K::Version, _) => "UPDATE versions SET s3_key = $1, scan_status = 'clean' WHERE id = $2", | |
| 292 | + | (K::Media, _) => "UPDATE media_files SET s3_key = $1, scan_status = 'clean' WHERE id = $2", | |
| 293 | + | (K::OtaArtifact, _) => { | |
| 294 | + | "UPDATE ota_artifacts SET s3_key = $1, scan_status = 'clean' WHERE id = $2" | |
| 295 | + | } | |
| 296 | + | (other_kind, other_ft) => { | |
| 297 | + | // A CDN-image kind (or an Item file role with no key column) must go | |
| 298 | + | // through `promote_cdn_image_by_key` instead; reaching here is a wiring | |
| 299 | + | // bug, not a data condition. | |
| 300 | + | return Err(sqlx::Error::Protocol(format!( | |
| 301 | + | "promote_gated: {other_kind:?}/{other_ft:?} is not a gated promote target" | |
| 302 | + | ))); | |
| 303 | + | } | |
| 304 | + | }; | |
| 305 | + | sqlx::query(sql) | |
| 306 | + | .bind(content_key) | |
| 307 | + | .bind(target_id) | |
| 308 | + | .execute(executor) | |
| 309 | + | .await?; | |
| 310 | + | Ok(()) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// Repoint every CDN-served image surface that currently references `staging_key` | |
| 314 | + | /// to the immutable `content_key` (and its rebuilt public `content_url`), marking | |
| 315 | + | /// the row Clean. Mirrors [`set_cdn_image_scan_status_by_key`]'s surface list so | |
| 316 | + | /// the promote and the status-stamp can't drift apart. Only the one surface whose | |
| 317 | + | /// key matches is touched; the others no-op. `content_insertions` carries no URL | |
| 318 | + | /// column (it is served presigned, not straight from the CDN) so only its key and | |
| 319 | + | /// status move. Returns the number of rows repointed (expected: exactly 1). | |
| 320 | + | pub async fn promote_cdn_image_by_key( | |
| 321 | + | conn: &mut sqlx::PgConnection, | |
| 322 | + | staging_key: &str, | |
| 323 | + | content_key: &str, | |
| 324 | + | content_url: &str, | |
| 325 | + | ) -> Result<u64, sqlx::Error> { | |
| 326 | + | let mut affected = 0u64; | |
| 327 | + | // (SQL, binds_content_url) — the surfaces with a materialized public URL take | |
| 328 | + | // three binds (key, url, where-key); content_insertions takes two. | |
| 329 | + | for (sql, has_url) in [ | |
| 330 | + | ( | |
| 331 | + | "UPDATE item_images SET s3_key = $1, image_url = $2, scan_status = 'clean' WHERE s3_key = $3", | |
| 332 | + | true, | |
| 333 | + | ), | |
| 334 | + | ( | |
| 335 | + | "UPDATE project_images SET s3_key = $1, image_url = $2, scan_status = 'clean' WHERE s3_key = $3", | |
| 336 | + | true, | |
| 337 | + | ), | |
| 338 | + | ( | |
| 339 | + | "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean' WHERE cover_s3_key = $3", | |
| 340 | + | true, | |
| 341 | + | ), | |
| 342 | + | ( | |
| 343 | + | "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean' WHERE cover_s3_key = $3", | |
| 344 | + | true, | |
| 345 | + | ), | |
| 346 | + | ( | |
| 347 | + | "UPDATE content_insertions SET storage_key = $1, scan_status = 'clean' WHERE storage_key = $2", | |
| 348 | + | false, | |
| 349 | + | ), | |
| 350 | + | ] { | |
| 351 | + | let q = if has_url { | |
| 352 | + | sqlx::query(sql).bind(content_key).bind(content_url).bind(staging_key) | |
| 353 | + | } else { | |
| 354 | + | sqlx::query(sql).bind(content_key).bind(staging_key) | |
| 355 | + | }; | |
| 356 | + | affected += q.execute(&mut *conn).await?.rows_affected(); | |
| 357 | + | } | |
| 358 | + | Ok(affected) | |
| 359 | + | } | |
| 360 | + | ||
| 243 | 361 | /// Get items held for review, joined with creator info + latest scan layers. | |
| 244 | 362 | /// Oldest first. | |
| 245 | 363 | #[tracing::instrument(skip_all)] |
| @@ -260,7 +260,10 @@ pub(super) async fn admin_promote_item( | |||
| 260 | 260 | AdminUser(admin): AdminUser, | |
| 261 | 261 | Path(id): Path<ItemId>, | |
| 262 | 262 | ) -> Result<Response> { | |
| 263 | - | db::scanning::update_item_scan_status(&state.db, id, FileScanStatus::Clean).await?; | |
| 263 | + | // Copy each held (staging) file to its content key and mark Clean in one | |
| 264 | + | // step — a held file sits unserved at its staging key, so approving must run | |
| 265 | + | // the same promote the scan worker's Clean path runs (C1). | |
| 266 | + | crate::routes::storage::commit_promote_item(&state, id).await?; | |
| 264 | 267 | db::scan_admin_actions::log_item( | |
| 265 | 268 | &state.db, id, admin.id, AdminAction::Promote, | |
| 266 | 269 | Some("held_for_review"), Some("clean"), | |
| @@ -294,7 +297,9 @@ pub(super) async fn admin_promote_version( | |||
| 294 | 297 | AdminUser(admin): AdminUser, | |
| 295 | 298 | Path(id): Path<VersionId>, | |
| 296 | 299 | ) -> Result<Response> { | |
| 297 | - | db::scanning::update_version_scan_status(&state.db, id, FileScanStatus::Clean).await?; | |
| 300 | + | // See admin_promote_item: copy the held (staging) download to its content key | |
| 301 | + | // and mark Clean together (C1). | |
| 302 | + | crate::routes::storage::commit_promote_version(&state, id).await?; | |
| 298 | 303 | db::scan_admin_actions::log_version( | |
| 299 | 304 | &state.db, id, admin.id, AdminAction::Promote, | |
| 300 | 305 | Some("held_for_review"), Some("clean"), | |
| @@ -504,14 +509,15 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 504 | 509 | let admin_id = admin.id; | |
| 505 | 510 | let vid = v.version_id; | |
| 506 | 511 | set.spawn(async move { | |
| 507 | - | if db::scanning::update_version_scan_status(&state.db, vid, FileScanStatus::Clean).await.is_ok() { | |
| 512 | + | if let Err(e) = crate::routes::storage::commit_promote_version(&state, vid).await { | |
| 513 | + | tracing::warn!(version_id = %vid, error = ?e, "bulk promote: version promote failed"); | |
| 514 | + | false | |
| 515 | + | } else { | |
| 508 | 516 | db::scan_admin_actions::log_version( | |
| 509 | 517 | &state.db, vid, admin_id, AdminAction::BulkPromote, | |
| 510 | 518 | Some("held_for_review"), Some("clean"), Some(¬e), | |
| 511 | 519 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 512 | 520 | true | |
| 513 | - | } else { | |
| 514 | - | false | |
| 515 | 521 | } | |
| 516 | 522 | }); | |
| 517 | 523 | } | |
| @@ -524,14 +530,15 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 524 | 530 | let admin_id = admin.id; | |
| 525 | 531 | let iid = i.item_id; | |
| 526 | 532 | set.spawn(async move { | |
| 527 | - | if db::scanning::update_item_scan_status(&state.db, iid, FileScanStatus::Clean).await.is_ok() { | |
| 533 | + | if let Err(e) = crate::routes::storage::commit_promote_item(&state, iid).await { | |
| 534 | + | tracing::warn!(item_id = %iid, error = ?e, "bulk promote: item promote failed"); | |
| 535 | + | false | |
| 536 | + | } else { | |
| 528 | 537 | db::scan_admin_actions::log_item( | |
| 529 | 538 | &state.db, iid, admin_id, AdminAction::BulkPromote, | |
| 530 | 539 | Some("held_for_review"), Some("clean"), Some(¬e), | |
| 531 | 540 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 532 | 541 | true | |
| 533 | - | } else { | |
| 534 | - | false | |
| 535 | 542 | } | |
| 536 | 543 | }); | |
| 537 | 544 | } |
| @@ -92,7 +92,10 @@ pub(super) async fn presign_insertion( | |||
| 92 | 92 | // Check storage quota before issuing presigned URL | |
| 93 | 93 | db::creator_tiers::check_presign_allowed(&state.db, user.id, FileType::Insertion).await?; | |
| 94 | 94 | ||
| 95 | - | let s3_key = S3Client::generate_insertion_key(user.id, &req.file_name); | |
| 95 | + | // Staging key (unserved); the scan worker promotes it to the content key on a | |
| 96 | + | // Clean verdict (C1). Insertion clips are served presigned from the stored | |
| 97 | + | // key, so promotion only repoints `content_insertions.storage_key`. | |
| 98 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 96 | 99 | ||
| 97 | 100 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 98 | 101 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| @@ -121,15 +124,11 @@ pub(super) async fn confirm_insertion( | |||
| 121 | 124 | user.check_not_suspended()?; | |
| 122 | 125 | let s3 = state.require_s3()?; | |
| 123 | 126 | ||
| 124 | - | // Validate S3 key belongs to this user (prevent cross-user file reference) | |
| 125 | - | let expected_prefix = format!("{}/insertions/", user.id); | |
| 126 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 127 | - | return Err(AppError::BadRequest( | |
| 128 | - | "Invalid upload key".to_string(), | |
| 129 | - | )); | |
| 130 | - | } | |
| 127 | + | // Ownership of the staging key is proved below (after the replay | |
| 128 | + | // short-circuit) via `pending_uploads` — a `staging/{uuid}` key carries no | |
| 129 | + | // user in its path for a prefix check to bind against. | |
| 131 | 130 | ||
| 132 | - | // Idempotent replay: a retried confirm for the same (deterministic) key must not | |
| 131 | + | // Idempotent replay: a retried confirm for the same key must not | |
| 133 | 132 | // re-charge storage or insert a duplicate row (`storage_key` has no UNIQUE). | |
| 134 | 133 | // Return the already-created insertion (ultra-fuzz Run 12 Storage: confirm | |
| 135 | 134 | // idempotency). | |
| @@ -145,6 +144,15 @@ pub(super) async fn confirm_insertion( | |||
| 145 | 144 | })); | |
| 146 | 145 | } | |
| 147 | 146 | ||
| 147 | + | // Authorize the staging key for a fresh confirm: the caller must have | |
| 148 | + | // presigned it (recorded against them in `pending_uploads`). Placed after the | |
| 149 | + | // replay short-circuit — which is authorized by the row already existing — | |
| 150 | + | // and before the size/tier reject paths, so an unowned (at most another | |
| 151 | + | // user's in-flight) staging object is never enqueued for deletion. | |
| 152 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 153 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 154 | + | } | |
| 155 | + | ||
| 148 | 156 | // Get real file size from S3 (never trust client-provided size) | |
| 149 | 157 | let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| { | |
| 150 | 158 | AppError::BadRequest("Upload not found. Please try uploading again.".to_string()) |
| @@ -65,7 +65,9 @@ pub(super) async fn presign_upload( | |||
| 65 | 65 | // Early quota check | |
| 66 | 66 | db::creator_tiers::check_presign_allowed(&state.db, actor.user_id(), file_type).await?; | |
| 67 | 67 | ||
| 68 | - | let s3_key = S3Client::generate_key(actor.user_id(), req.item_id, file_type, &req.file_name); | |
| 68 | + | // Staging key (unserved); the scan worker promotes it to the content key on a | |
| 69 | + | // Clean verdict. See routes/storage/uploads.rs for the C1 rationale. | |
| 70 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 69 | 71 | ||
| 70 | 72 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 71 | 73 | db::pending_uploads::record_pending_upload(&state.db, actor.user_id(), &s3_key, "main").await?; | |
| @@ -126,11 +128,9 @@ pub(super) async fn confirm_upload( | |||
| 126 | 128 | return Err(AppError::Forbidden); | |
| 127 | 129 | } | |
| 128 | 130 | ||
| 129 | - | // Validate S3 key belongs to this user + item (prevent cross-user file reference) | |
| 130 | - | let expected_prefix = format!("{}/{}/", actor.user_id(), req.item_id); | |
| 131 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 132 | - | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 133 | - | } | |
| 131 | + | // Ownership of the staging key is proved below (after the idempotent-replay | |
| 132 | + | // short-circuit) via `pending_uploads` — a `staging/{uuid}` key carries no | |
| 133 | + | // user/item in its path for a prefix check to bind against. | |
| 134 | 134 | ||
| 135 | 135 | // Verify the object exists in S3 | |
| 136 | 136 | if !s3.object_exists(&req.s3_key).await? { | |
| @@ -168,6 +168,15 @@ pub(super) async fn confirm_upload( | |||
| 168 | 168 | return Ok(Json(InternalConfirmResponse { success: true })); | |
| 169 | 169 | } | |
| 170 | 170 | ||
| 171 | + | // Authorize the staging key for a fresh confirm: the caller must have | |
| 172 | + | // presigned it (recorded against them in `pending_uploads`). Placed after the | |
| 173 | + | // replay short-circuit — which consumed the pending row on the first confirm — | |
| 174 | + | // and before any reject path that enqueues the object for deletion, so an | |
| 175 | + | // unowned (at most another user's in-flight) staging object is never touched. | |
| 176 | + | if !db::pending_uploads::is_owned(&state.db, actor.user_id(), &req.s3_key, "main").await? { | |
| 177 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 178 | + | } | |
| 179 | + | ||
| 171 | 180 | // Enforce file size limit | |
| 172 | 181 | let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| { | |
| 173 | 182 | AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string()) |
| @@ -284,7 +284,9 @@ async fn upload_artifact( | |||
| 284 | 284 | Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, | |
| 285 | 285 | Json(req): Json<UploadArtifactRequest>, | |
| 286 | 286 | ) -> Result<impl IntoResponse> { | |
| 287 | - | let app = verify_app_owner(&state, &sync_user, app_id).await?; | |
| 287 | + | // Ownership check (side effect); the app object itself is no longer needed to | |
| 288 | + | // build the key now that artifacts land at a random staging key. | |
| 289 | + | verify_app_owner(&state, &sync_user, app_id).await?; | |
| 288 | 290 | validate_target(&req.target)?; | |
| 289 | 291 | validate_arch(&req.arch)?; | |
| 290 | 292 | ||
| @@ -292,14 +294,16 @@ async fn upload_artifact( | |||
| 292 | 294 | return Err(AppError::BadRequest("file_size must be positive".to_string())); | |
| 293 | 295 | } | |
| 294 | 296 | ||
| 295 | - | // Verify the release belongs to this app (scoped lookup, not a full list scan) | |
| 296 | - | let release = db::ota::get_release(&state.db, app_id, release_id) | |
| 297 | + | // Verify the release belongs to this app (scoped lookup, not a full list scan). | |
| 298 | + | db::ota::get_release(&state.db, app_id, release_id) | |
| 297 | 299 | .await? | |
| 298 | 300 | .ok_or(AppError::NotFound)?; | |
| 299 | 301 | ||
| 300 | - | let s3_key = crate::storage::S3Client::generate_ota_artifact_key( | |
| 301 | - | app.id, &release.version, &req.target, &req.arch, | |
| 302 | - | ); | |
| 302 | + | // Staging key (unserved); the scan worker promotes it to the content key on a | |
| 303 | + | // Clean verdict (C1). The artifact ROW stays singleton per | |
| 304 | + | // (release, target, arch) — `create_artifact` below overwrites its `s3_key` | |
| 305 | + | // pointer on re-upload — so the object no longer needs a deterministic name. | |
| 306 | + | let s3_key = crate::storage::S3Client::generate_staging_key("artifact.bin"); | |
| 303 | 307 | ||
| 304 | 308 | let synckit_s3 = state.require_synckit_s3()?; | |
| 305 | 309 |
| @@ -146,16 +146,10 @@ pub(super) async fn gallery_presign( | |||
| 146 | 146 | // Validate the declared size (if any) before signing it into Content-Length. | |
| 147 | 147 | super::validate_declared_upload_size(req.file_size_bytes, file_type, None)?; | |
| 148 | 148 | ||
| 149 | - | // Per-image uuid keeps multiple gallery uploads from colliding. | |
| 150 | - | let image_uuid = Uuid::new_v4(); | |
| 151 | - | let s3_key = match target { | |
| 152 | - | GalleryTarget::Item => { | |
| 153 | - | S3Client::generate_item_gallery_key(user.id, ItemId::from(req.target_id), image_uuid, &req.file_name) | |
| 154 | - | } | |
| 155 | - | GalleryTarget::Project => { | |
| 156 | - | S3Client::generate_project_gallery_key(ProjectId::from(req.target_id), image_uuid, &req.file_name) | |
| 157 | - | } | |
| 158 | - | }; | |
| 149 | + | // Staging key (unserved); the scan worker promotes it to the content key and | |
| 150 | + | // rebuilds the row's public `image_url` on a Clean verdict (C1). Its random | |
| 151 | + | // uuid also keeps multiple gallery uploads from colliding. | |
| 152 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 159 | 153 | ||
| 160 | 154 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 161 | 155 | ||
| @@ -216,14 +210,9 @@ pub(super) async fn gallery_confirm( | |||
| 216 | 210 | ||
| 217 | 211 | require_owned(&state, target, req.target_id, user.id).await?; | |
| 218 | 212 | ||
| 219 | - | // Key must live under this entity's gallery prefix (no cross-entity steal). | |
| 220 | - | let expected_prefix = match target { | |
| 221 | - | GalleryTarget::Item => format!("{}/{}/gallery/", user.id, req.target_id), | |
| 222 | - | GalleryTarget::Project => format!("projects/{}/gallery/", req.target_id), | |
| 223 | - | }; | |
| 224 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 225 | - | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 226 | - | } | |
| 213 | + | // Ownership of the staging key is proved inside the confirm transaction below | |
| 214 | + | // (after the replay short-circuit) via `pending_uploads` — a `staging/{uuid}` | |
| 215 | + | // key carries no entity in its path for a prefix check to bind against. | |
| 227 | 216 | ||
| 228 | 217 | if !s3.object_exists(&req.s3_key).await? { | |
| 229 | 218 | return Err(AppError::BadRequest( | |
| @@ -319,6 +308,15 @@ pub(super) async fn gallery_confirm( | |||
| 319 | 308 | if let Some(img) = existing { | |
| 320 | 309 | return Ok(GalleryOutcome::Existing(img)); | |
| 321 | 310 | } | |
| 311 | + | // Authorize a FRESH insert: the caller must have presigned this staging | |
| 312 | + | // key (recorded against them in `pending_uploads`). Checked here, after | |
| 313 | + | // the replay short-circuit — a replayed confirm's pending row was already | |
| 314 | + | // consumed — so only new rows are gated. A `staging/{uuid}` key has no | |
| 315 | + | // entity in its path, so this lookup (not a prefix check) is what stops a | |
| 316 | + | // confirm from stealing another user's staging object. | |
| 317 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 318 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 319 | + | } | |
| 322 | 320 | let count_in_tx = match target { | |
| 323 | 321 | GalleryTarget::Item => db::gallery_images::count_for_item(&mut *tx, ItemId::from(req.target_id)).await?, | |
| 324 | 322 | GalleryTarget::Project => db::gallery_images::count_for_project(&mut *tx, ProjectId::from(req.target_id)).await?, |
| @@ -84,7 +84,9 @@ pub(super) async fn project_image_presign( | |||
| 84 | 84 | // Early quota check | |
| 85 | 85 | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 86 | 86 | ||
| 87 | - | let s3_key = S3Client::generate_project_image_key(req.project_id, &req.file_name); | |
| 87 | + | // Staging key (unserved); the scan worker promotes it to the content key and | |
| 88 | + | // rebuilds the public `cover_image_url` on a Clean verdict (C1). | |
| 89 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 88 | 90 | ||
| 89 | 91 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 90 | 92 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| @@ -126,12 +128,13 @@ pub(super) async fn project_image_confirm( | |||
| 126 | 128 | return Err(AppError::Forbidden); | |
| 127 | 129 | } | |
| 128 | 130 | ||
| 129 | - | // Validate S3 key belongs to this project (prevent cross-project file reference) | |
| 130 | - | let expected_prefix = format!("projects/{}/image/", req.project_id); | |
| 131 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 132 | - | return Err(AppError::BadRequest( | |
| 133 | - | "Invalid upload key".to_string(), | |
| 134 | - | )); | |
| 131 | + | // Authorize the staging key: a `staging/{uuid}` key has no project in its | |
| 132 | + | // path, so ownership is proved via the `pending_uploads` row recorded at | |
| 133 | + | // presign — not a prefix check. Gate before the size-reject path so an | |
| 134 | + | // unowned (at most another user's in-flight) staging object is never enqueued | |
| 135 | + | // for deletion. | |
| 136 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 137 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 135 | 138 | } | |
| 136 | 139 | ||
| 137 | 140 | // A single HEAD: `object_size` returns None when the object isn't there, so | |
| @@ -336,7 +339,9 @@ pub(super) async fn item_image_presign( | |||
| 336 | 339 | // Early quota check | |
| 337 | 340 | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 338 | 341 | ||
| 339 | - | let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name); | |
| 342 | + | // Staging key (unserved); the scan worker promotes it to the content key and | |
| 343 | + | // rebuilds the public `cover_image_url` on a Clean verdict (C1). | |
| 344 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 340 | 345 | ||
| 341 | 346 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 342 | 347 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| @@ -378,13 +383,12 @@ pub(super) async fn item_image_confirm( | |||
| 378 | 383 | return Err(AppError::Forbidden); | |
| 379 | 384 | } | |
| 380 | 385 | ||
| 381 | - | // Validate the key belongs to this user + item AND sits under the `cover/` | |
| 382 | - | // segment that `generate_key(.., Cover, ..)` mints — not just `{user}/{item}/`. | |
| 383 | - | // The loose prefix let a confirm point cover_s3_key at any object the user | |
| 384 | - | // owns under the item (e.g. a `download/` payload), asymmetric with the | |
| 385 | - | // version path's full `{user}/{item}/download/{version}/` pin (Run 11 Storage LOW). | |
| 386 | - | let expected_prefix = format!("{}/{}/{}/", user.id, req.item_id, FileType::Cover.as_str()); | |
| 387 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 386 | + | // Authorize the staging key: a `staging/{uuid}` key has no user/item in its | |
| 387 | + | // path (and no `cover/` segment to pin), so ownership — and the fact that it | |
| 388 | + | // was minted for THIS user's upload flow — is proved via the `pending_uploads` | |
| 389 | + | // row recorded at presign, not a prefix check. Gate before the size-reject | |
| 390 | + | // path so an unowned staging object is never enqueued for deletion. | |
| 391 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 388 | 392 | return Err(AppError::BadRequest( | |
| 389 | 393 | "Invalid upload key".to_string(), | |
| 390 | 394 | )); |
| @@ -16,7 +16,7 @@ use crate::{ | |||
| 16 | 16 | auth::AuthUser, | |
| 17 | 17 | db::{self, MediaFileId}, | |
| 18 | 18 | error::{AppError, Result, ResultExt}, | |
| 19 | - | storage::{sanitize_folder, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 19 | + | storage::{sanitize_filename, sanitize_folder, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 20 | 20 | AppState, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| @@ -44,9 +44,15 @@ pub struct MediaConfirmRequest { | |||
| 44 | 44 | pub s3_key: String, | |
| 45 | 45 | pub file_name: String, | |
| 46 | 46 | pub content_type: String, | |
| 47 | - | // `folder` is no longer accepted at confirm: it is derived from the validated | |
| 48 | - | // s3_key (a client-supplied folder could disagree with the object's actual | |
| 49 | - | // key). Any `folder` field in the request body is ignored by serde. | |
| 47 | + | // The logical library name (folder + filename) is carried on the confirm. | |
| 48 | + | // Under scan-then-promote the physical key is a content hash that no longer | |
| 49 | + | // encodes the name, so there is nothing in the key for a client-supplied | |
| 50 | + | // folder to disagree with (the Run #22 mismatch concern is gone) — the | |
| 51 | + | // `(user_id, folder, filename)` unique index guards the logical namespace, | |
| 52 | + | // decoupled from the content-addressed object. Both are re-sanitized at | |
| 53 | + | // confirm. Defaulted so a client omitting it lands the file in the root. | |
| 54 | + | #[serde(default)] | |
| 55 | + | pub folder: String, | |
| 50 | 56 | } | |
| 51 | 57 | ||
| 52 | 58 | #[derive(Debug, Deserialize)] | |
| @@ -98,10 +104,13 @@ fn classify_media(content_type: &str) -> Result<(&'static str, FileType)> { | |||
| 98 | 104 | ||
| 99 | 105 | fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse { | |
| 100 | 106 | let cdn_url = format!("{}/{}", cdn_base, f.s3_key); | |
| 107 | + | // Folder-relative embed path, built from the logical name on the row — not | |
| 108 | + | // the physical key, which under scan-then-promote is a content hash | |
| 109 | + | // (`{user}/c/{sha}.ext`) that no longer encodes folder/filename. | |
| 101 | 110 | let markdown_ref = if f.folder.is_empty() { | |
| 102 | 111 | format!("", f.filename) | |
| 103 | 112 | } else { | |
| 104 | - | format!("", f.s3_key.trim_start_matches(&format!("{}/media/", f.user_id))) | |
| 113 | + | format!("", f.folder, f.filename) | |
| 105 | 114 | }; | |
| 106 | 115 | MediaFileResponse { | |
| 107 | 116 | id: f.id, | |
| @@ -168,8 +177,12 @@ pub(super) async fn media_presign( | |||
| 168 | 177 | // both try to upload and one wastes bandwidth) and the confirm-time | |
| 169 | 178 | // path is authoritative either way. | |
| 170 | 179 | ||
| 171 | - | // Generate S3 key | |
| 172 | - | let s3_key = S3Client::generate_media_key(user.id, &folder, &req.file_name); | |
| 180 | + | // Staging key (unserved); the scan worker promotes it to the content key on a | |
| 181 | + | // Clean verdict (C1). The logical library name (folder + filename) is no | |
| 182 | + | // longer encoded in the physical key — it is carried on the row and re-derived | |
| 183 | + | // from the (sanitized) request at confirm. | |
| 184 | + | let _ = &folder; // validated above for early rejection; not woven into the key | |
| 185 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 173 | 186 | ||
| 174 | 187 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 175 | 188 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| @@ -207,9 +220,12 @@ pub(super) async fn media_confirm( | |||
| 207 | 220 | S3Client::validate_content_type(file_type, &req.content_type)?; | |
| 208 | 221 | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 209 | 222 | ||
| 210 | - | // Validate S3 key belongs to this user (prevent cross-user file reference) | |
| 211 | - | let expected_prefix = format!("{}/media/", user.id); | |
| 212 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 223 | + | // Authorize the staging key: a `staging/{uuid}` key has no user in its path, | |
| 224 | + | // so ownership is proved via the `pending_uploads` row recorded at presign, | |
| 225 | + | // not a prefix check. Gate before the sniff/size-reject paths so an unowned | |
| 226 | + | // (at most another user's in-flight) staging object is never enqueued for | |
| 227 | + | // deletion. | |
| 228 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 213 | 229 | return Err(AppError::BadRequest( | |
| 214 | 230 | "Invalid upload key".to_string(), | |
| 215 | 231 | )); | |
| @@ -288,19 +304,19 @@ pub(super) async fn media_confirm( | |||
| 288 | 304 | } | |
| 289 | 305 | }; | |
| 290 | 306 | ||
| 291 | - | // Derive folder + filename from the validated S3 key, not the separately | |
| 292 | - | // supplied req.folder / req.file_name (which can disagree with the key the | |
| 293 | - | // object actually lives under — Run #22 Storage MED: the unique index then | |
| 294 | - | // guards a name that doesn't match the object). The key was built at presign | |
| 295 | - | // as `{user}/media/[{folder}/]{filename}` with these same sanitizers, so its | |
| 296 | - | // tail is authoritative and already sanitized. | |
| 297 | - | let key_tail = req.s3_key.strip_prefix(&expected_prefix).unwrap_or(""); | |
| 298 | - | let (folder, safe_filename) = match key_tail.rsplit_once('/') { | |
| 299 | - | Some((f, name)) => (f.to_string(), name.to_string()), | |
| 300 | - | None => (String::new(), key_tail.to_string()), | |
| 301 | - | }; | |
| 307 | + | // Derive the logical library name (folder + filename) from the request, | |
| 308 | + | // re-applying the same sanitizers presign used. Under scan-then-promote the | |
| 309 | + | // physical key is a content hash that no longer encodes the name (the Run #22 | |
| 310 | + | // key-vs-name mismatch it guarded against is gone — the name is now a purely | |
| 311 | + | // logical namespace, enforced by the `(user_id, folder, filename)` unique | |
| 312 | + | // index, decoupled from the content-addressed object). | |
| 313 | + | if req.folder.contains("..") { | |
| 314 | + | return Err(AppError::BadRequest("Invalid folder name".to_string())); | |
| 315 | + | } | |
| 316 | + | let folder = sanitize_folder(&req.folder); | |
| 317 | + | let safe_filename = sanitize_filename(&req.file_name); | |
| 302 | 318 | if safe_filename.is_empty() { | |
| 303 | - | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 319 | + | return Err(AppError::BadRequest("Invalid file name".to_string())); | |
| 304 | 320 | } | |
| 305 | 321 | ||
| 306 | 322 | // Wrap storage credit + pending_uploads clear + media_files INSERT in a |
| @@ -357,6 +357,115 @@ pub(crate) async fn commit_rescan( | |||
| 357 | 357 | Ok(pending) | |
| 358 | 358 | } | |
| 359 | 359 | ||
| 360 | + | /// Admin approve-held: promote an item's held gated files (audio/video) to their | |
| 361 | + | /// content keys, then mark the item Clean. A held file sits at its unserved | |
| 362 | + | /// staging key (the scan didn't promote it), so approving it must run the SAME | |
| 363 | + | /// copy-then-repoint the scan worker's Clean path runs — otherwise the item is | |
| 364 | + | /// marked Clean while still pointing at a staging object that is about to be | |
| 365 | + | /// reaped. Factored here (beside `commit_rescan`) so the worker and admin promote | |
| 366 | + | /// can't diverge (the anti-drift discipline the scan-ordering seal uses). | |
| 367 | + | /// | |
| 368 | + | /// Idempotent: a file already at a `{owner}/c/…` content key is skipped, so a | |
| 369 | + | /// bulk approve over a mixed set (some already promoted) is safe. | |
| 370 | + | #[tracing::instrument(skip_all, name = "storage::commit_promote_item", fields(%item_id))] | |
| 371 | + | pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) -> Result<()> { | |
| 372 | + | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 373 | + | .await? | |
| 374 | + | .ok_or(AppError::NotFound)?; | |
| 375 | + | let owner = db::items::get_item_owner(&state.db, item_id) | |
| 376 | + | .await? | |
| 377 | + | .ok_or(AppError::NotFound)?; | |
| 378 | + | // Only the files still at a staging key need a copy; skip a file that is | |
| 379 | + | // absent or already content-keyed (bulk approve over a mixed set). Scan | |
| 380 | + | // results are recorded against the staging key, so a hash lookup on a content | |
| 381 | + | // key would miss — hence the staging check gates everything, including the | |
| 382 | + | // `require_s3` below (a held row with nothing to promote — e.g. no S3 backend | |
| 383 | + | // configured — still marks Clean without needing storage). | |
| 384 | + | let staged: Vec<(String, FileType)> = [ | |
| 385 | + | (item.audio_s3_key.clone(), FileType::Audio), | |
| 386 | + | (item.video_s3_key.clone(), FileType::Video), | |
| 387 | + | ] | |
| 388 | + | .into_iter() | |
| 389 | + | .filter_map(|(key, ft)| match key { | |
| 390 | + | Some(k) if k.starts_with("staging/") => Some((k, ft)), | |
| 391 | + | _ => None, | |
| 392 | + | }) | |
| 393 | + | .collect(); | |
| 394 | + | if !staged.is_empty() { | |
| 395 | + | let s3 = state.require_s3()?; | |
| 396 | + | for (staging_key, file_type) in staged { | |
| 397 | + | let sha256 = db::scanning::latest_sha256_by_key(&state.db, &staging_key) | |
| 398 | + | .await? | |
| 399 | + | .ok_or_else(|| { | |
| 400 | + | AppError::Storage(format!( | |
| 401 | + | "cannot approve item {item_id}: no scan hash recorded for {staging_key}" | |
| 402 | + | )) | |
| 403 | + | })?; | |
| 404 | + | crate::scanning::promote_staging_to_content( | |
| 405 | + | &state.db, | |
| 406 | + | s3.as_ref(), | |
| 407 | + | state.config.cdn_base_url.as_deref(), | |
| 408 | + | ScanTargetKind::Item, | |
| 409 | + | file_type, | |
| 410 | + | item_id.into(), | |
| 411 | + | owner, | |
| 412 | + | &staging_key, | |
| 413 | + | &sha256, | |
| 414 | + | crate::storage::S3Bucket::Main, | |
| 415 | + | ) | |
| 416 | + | .await?; | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | // `promote_gated` already set `scan_status = 'clean'` per promoted file; this | |
| 420 | + | // also covers the no-staged-file case (all already content-keyed) so the | |
| 421 | + | // admin action still lands the item Clean. | |
| 422 | + | db::scanning::update_item_scan_status(&state.db, item_id, db::FileScanStatus::Clean).await?; | |
| 423 | + | Ok(()) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | /// Admin approve-held: promote a version's held download file to its content key, | |
| 427 | + | /// then mark the version Clean. See [`commit_promote_item`] for why the copy must | |
| 428 | + | /// happen at approve time and why it is factored here. | |
| 429 | + | #[tracing::instrument(skip_all, name = "storage::commit_promote_version", fields(%version_id))] | |
| 430 | + | pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::VersionId) -> Result<()> { | |
| 431 | + | let version = db::versions::get_version_by_id(&state.db, version_id) | |
| 432 | + | .await? | |
| 433 | + | .ok_or(AppError::NotFound)?; | |
| 434 | + | let owner = db::items::get_item_owner(&state.db, version.item_id) | |
| 435 | + | .await? | |
| 436 | + | .ok_or(AppError::NotFound)?; | |
| 437 | + | // `require_s3` only when there is a staging file to copy — a held row with no | |
| 438 | + | // staging download (or an already content-keyed one) still marks Clean with no | |
| 439 | + | // storage backend needed. | |
| 440 | + | if let Some(staging_key) = version.s3_key.clone() | |
| 441 | + | && staging_key.starts_with("staging/") | |
| 442 | + | { | |
| 443 | + | let s3 = state.require_s3()?; | |
| 444 | + | let sha256 = db::scanning::latest_sha256_by_key(&state.db, &staging_key) | |
| 445 | + | .await? | |
| 446 | + | .ok_or_else(|| { | |
| 447 | + | AppError::Storage(format!( | |
| 448 | + | "cannot approve version {version_id}: no scan hash recorded for {staging_key}" | |
| 449 | + | )) | |
| 450 | + | })?; | |
| 451 | + | crate::scanning::promote_staging_to_content( | |
| 452 | + | &state.db, | |
| 453 | + | s3.as_ref(), | |
| 454 | + | state.config.cdn_base_url.as_deref(), | |
| 455 | + | ScanTargetKind::Version, | |
| 456 | + | FileType::Download, | |
| 457 | + | version_id.into(), | |
| 458 | + | owner, | |
| 459 | + | &staging_key, | |
| 460 | + | &sha256, | |
| 461 | + | crate::storage::S3Bucket::Main, | |
| 462 | + | ) | |
| 463 | + | .await?; | |
| 464 | + | } | |
| 465 | + | db::scanning::update_version_scan_status(&state.db, version_id, db::FileScanStatus::Clean).await?; | |
| 466 | + | Ok(()) | |
| 467 | + | } | |
| 468 | + | ||
| 360 | 469 | /// Enqueue an async scan job for an uploaded file and return the initial | |
| 361 | 470 | /// `scan_status` to write onto the target entity. | |
| 362 | 471 | /// |
| @@ -91,8 +91,11 @@ pub(super) async fn presign_upload( | |||
| 91 | 91 | // (no protocol-level enforcement; confirm step still validates). | |
| 92 | 92 | super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?; | |
| 93 | 93 | ||
| 94 | - | // Generate S3 key | |
| 95 | - | let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name); | |
| 94 | + | // Presign to an unserved staging key, never the served key. After a Clean | |
| 95 | + | // scan the worker copies the object to its content-addressed served key and | |
| 96 | + | // deletes this staging object, so a re-PUT to this presigned URL after the | |
| 97 | + | // scan can't change the bytes a buyer is served (C1). | |
| 98 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 96 | 99 | ||
| 97 | 100 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 98 | 101 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| @@ -140,13 +143,9 @@ pub(super) async fn confirm_upload( | |||
| 140 | 143 | return Err(AppError::Forbidden); | |
| 141 | 144 | } | |
| 142 | 145 | ||
| 143 | - | // Validate S3 key belongs to this user + item (prevent cross-user file reference) | |
| 144 | - | let expected_prefix = format!("{}/{}/", user.id, req.item_id); | |
| 145 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 146 | - | return Err(AppError::BadRequest( | |
| 147 | - | "Invalid upload key".to_string(), | |
| 148 | - | )); | |
| 149 | - | } | |
| 146 | + | // Ownership of the staging key is proved below (after the idempotent | |
| 147 | + | // re-confirm short-circuit) via `pending_uploads`, since a `staging/{uuid}` | |
| 148 | + | // key carries no user/item in its path for a prefix check to bind. | |
| 150 | 149 | ||
| 151 | 150 | // Verify the object exists in S3 | |
| 152 | 151 | if !s3.object_exists(&req.s3_key).await? { | |
| @@ -228,6 +227,22 @@ pub(super) async fn confirm_upload( | |||
| 228 | 227 | } | |
| 229 | 228 | let is_replace = old_s3_key.is_some(); | |
| 230 | 229 | ||
| 230 | + | // Authorize the staging key for a *fresh* confirm: the caller must have | |
| 231 | + | // presigned it (recorded against them in `pending_uploads`). The idempotent | |
| 232 | + | // re-confirm above already returned — it consumed the pending row on the | |
| 233 | + | // first confirm and is authorized by the item ownership check + the entity | |
| 234 | + | // already referencing the key — so this gate only guards new writes. A | |
| 235 | + | // `staging/{uuid}` key has no owner in its path, so this lookup (not a prefix | |
| 236 | + | // check) is what prevents confirming another user's staging object. | |
| 237 | + | // Do NOT enqueue this key for orphan deletion on failure: an unowned staging | |
| 238 | + | // key is (at most) another user's in-flight upload, and the deletion queue | |
| 239 | + | // would treat the not-yet-referenced staging object as dead and delete it — | |
| 240 | + | // a cross-user griefing delete. Just reject; the real owner's confirm or the | |
| 241 | + | // pending-upload reaper handles their key. | |
| 242 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 243 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 244 | + | } | |
| 245 | + | ||
| 231 | 246 | // Storage credit + item UPDATE in ONE transaction. A rollback restores both, | |
| 232 | 247 | // so a mid-write failure can't leave the storage counter inflated against a | |
| 233 | 248 | // row that never got the key (the previous compensating-action path with |
| @@ -77,10 +77,11 @@ pub(super) async fn version_presign_upload( | |||
| 77 | 77 | // Validate the declared size (if any) before signing it into Content-Length. | |
| 78 | 78 | super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?; | |
| 79 | 79 | ||
| 80 | - | // Generate S3 key woven with the version id so two versions of the same | |
| 81 | - | // item sharing a filename never collide onto one object (ultra-fuzz Run #1 | |
| 82 | - | // Storage HIGH). The dedicated generator embeds the download prefix. | |
| 83 | - | let s3_key = S3Client::generate_version_key(user.id, version.item_id, version_id, &req.file_name); | |
| 80 | + | // Staging key (unserved); the scan worker promotes it to the content key on | |
| 81 | + | // a Clean verdict. The random staging uuid also guarantees two versions that | |
| 82 | + | // share a filename never collide onto one object (the property the old | |
| 83 | + | // version-id-woven key gave us — ultra-fuzz Run #1 Storage HIGH). | |
| 84 | + | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 84 | 85 | ||
| 85 | 86 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 86 | 87 | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| @@ -127,16 +128,26 @@ pub(super) async fn version_confirm_upload( | |||
| 127 | 128 | return Err(AppError::Forbidden); | |
| 128 | 129 | } | |
| 129 | 130 | ||
| 130 | - | // Validate the S3 key belongs to this user + item + THIS version. The | |
| 131 | - | // generated key is `{user}/{item}/download/{version_id}/{filename}`, so pin | |
| 132 | - | // the full prefix including the version segment — a looser `{user}/{item}/` | |
| 133 | - | // check would let a creator confirm one version's row against another | |
| 134 | - | // version's key (ultra-fuzz Run 10 Sto M-1). | |
| 135 | - | let expected_prefix = format!("{}/{}/download/{}/", user.id, version.item_id, version_id); | |
| 136 | - | if !req.s3_key.starts_with(&expected_prefix) { | |
| 137 | - | return Err(AppError::BadRequest( | |
| 138 | - | "Invalid upload key".to_string(), | |
| 139 | - | )); | |
| 131 | + | // Idempotent re-confirm: the version already references this exact key. | |
| 132 | + | // Handled BEFORE the ownership gate and the scan enqueue — re-confirming an | |
| 133 | + | // already-Clean version must not knock it back to Pending, and the pending | |
| 134 | + | // row was consumed by the first confirm so the gate below would reject it. | |
| 135 | + | if version.s3_key.as_deref() == Some(&req.s3_key) { | |
| 136 | + | // Still clear pending_uploads — orphan reaper would otherwise delete the | |
| 137 | + | // live S3 object 24h later (Run #7 HIGH-1). | |
| 138 | + | if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await { | |
| 139 | + | tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); | |
| 140 | + | } | |
| 141 | + | return Ok(Json(ConfirmUploadResponse { success: true, pending_review: None })); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Authorize the staging key for a fresh confirm. A `staging/{uuid}` key has | |
| 145 | + | // no user/item/version in its path, so ownership is proved via the | |
| 146 | + | // `pending_uploads` row recorded at presign — not a prefix check. Placed | |
| 147 | + | // before the size/tier reject paths so an unowned (at most another user's | |
| 148 | + | // in-flight) staging object is never enqueued for deletion. | |
| 149 | + | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 150 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 140 | 151 | } | |
| 141 | 152 | ||
| 142 | 153 | // A single HEAD: `object_size` returns None when the object isn't there, so | |
| @@ -164,18 +175,6 @@ pub(super) async fn version_confirm_upload( | |||
| 164 | 175 | } | |
| 165 | 176 | }; | |
| 166 | 177 | ||
| 167 | - | // Idempotency: if the version already has this exact s3_key, return success (no-op). | |
| 168 | - | // Must come BEFORE scan enqueue / scan_status flip — re-confirming an already-Clean | |
| 169 | - | // version must not knock it back to Pending. | |
| 170 | - | if version.s3_key.as_deref() == Some(&req.s3_key) { | |
| 171 | - | // Still clear pending_uploads — orphan reaper would otherwise delete | |
| 172 | - | // the live S3 object 24h later (Run #7 HIGH-1). | |
| 173 | - | if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await { | |
| 174 | - | tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); | |
| 175 | - | } | |
| 176 | - | return Ok(Json(ConfirmUploadResponse { success: true, pending_review: None })); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | 178 | let old_s3_key = version.s3_key.clone(); | |
| 180 | 179 | let old_size = version.file_size_bytes.unwrap_or(0); | |
| 181 | 180 | let is_replace = old_s3_key.is_some() && old_size > 0; |
| @@ -256,6 +256,82 @@ fn too_large_to_scan(file_size: u64) -> ScanResult { | |||
| 256 | 256 | } | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | + | /// Scan-then-promote closing move (C1): copy a Clean object from its unserved | |
| 260 | + | /// `staging/{uuid}` key to the immutable, content-addressed | |
| 261 | + | /// `{owner}/c/{sha256}.{ext}` key, repoint the entity (and, for CDN images, its | |
| 262 | + | /// materialized public URL) at the content key, mark it Clean, and enqueue the | |
| 263 | + | /// staging object for durable deletion. The bytes a buyer is served are then | |
| 264 | + | /// provably the bytes that were scanned: the content key is named by the scanned | |
| 265 | + | /// hash, the owner holds no presign to it, and the (owner-less, random) staging | |
| 266 | + | /// key they *can* re-PUT to is unserved and deleted. | |
| 267 | + | /// | |
| 268 | + | /// Shared by the scan worker's Clean path and the admin approve-held path so the | |
| 269 | + | /// two promote sites can't diverge. Ordering is fail-safe: the S3 copy runs | |
| 270 | + | /// first, then a single transaction repoints the row and enqueues the staging | |
| 271 | + | /// delete. If the copy or the DB write fails, the row keeps pointing at the | |
| 272 | + | /// still-present (unserved) staging key and the caller leaves the work un-done, | |
| 273 | + | /// so a retry re-promotes — the copy is idempotent (hash-named destination), and | |
| 274 | + | /// an already-promoted `{owner}/c/…` key short-circuits. | |
| 275 | + | #[allow(clippy::too_many_arguments)] | |
| 276 | + | pub async fn promote_staging_to_content( | |
| 277 | + | db: &sqlx::PgPool, | |
| 278 | + | backend: &dyn crate::storage::StorageBackend, | |
| 279 | + | cdn_base_url: Option<&str>, | |
| 280 | + | kind: crate::db::scan_jobs::ScanTargetKind, | |
| 281 | + | file_type: FileType, | |
| 282 | + | target_id: uuid::Uuid, | |
| 283 | + | owner: crate::db::UserId, | |
| 284 | + | staging_key: &str, | |
| 285 | + | sha256: &str, | |
| 286 | + | bucket: crate::storage::S3Bucket, | |
| 287 | + | ) -> crate::error::Result<()> { | |
| 288 | + | use crate::db::scan_jobs::ScanTargetKind; | |
| 289 | + | use crate::storage::{S3Client, S3Key}; | |
| 290 | + | ||
| 291 | + | if sha256.is_empty() { | |
| 292 | + | return Err(crate::error::AppError::Storage(format!( | |
| 293 | + | "cannot promote {staging_key}: scan recorded no content hash" | |
| 294 | + | ))); | |
| 295 | + | } | |
| 296 | + | // Already promoted (worker/admin retry, admin bulk over a mixed set): a | |
| 297 | + | // content key is `{owner}/c/…`, never `staging/…`. Nothing to copy or repoint. | |
| 298 | + | if !staging_key.starts_with("staging/") { | |
| 299 | + | return Ok(()); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | let ext = crate::storage::key_extension(staging_key); | |
| 303 | + | let content = S3Client::content_key(owner, sha256, ext); | |
| 304 | + | let content_str = content.as_str().to_string(); | |
| 305 | + | ||
| 306 | + | // 1. Promote the object in storage (copy staging -> content). Idempotent. | |
| 307 | + | backend | |
| 308 | + | .copy_object(&S3Key::from_stored(staging_key), &content) | |
| 309 | + | .await?; | |
| 310 | + | ||
| 311 | + | // 2. Repoint the row + enqueue the staging delete atomically. | |
| 312 | + | let mut tx = db.begin().await?; | |
| 313 | + | if kind.is_cdn_served_without_gate() { | |
| 314 | + | let content_url = if matches!(kind, ScanTargetKind::ContentInsertion) { | |
| 315 | + | // No materialized URL column (insertions are served presigned). | |
| 316 | + | String::new() | |
| 317 | + | } else { | |
| 318 | + | crate::storage::build_project_image_url(backend, cdn_base_url, &content_str).await? | |
| 319 | + | }; | |
| 320 | + | crate::db::scanning::promote_cdn_image_by_key(&mut tx, staging_key, &content_str, &content_url) | |
| 321 | + | .await?; | |
| 322 | + | } else { | |
| 323 | + | crate::db::scanning::promote_gated(&mut *tx, kind, file_type, target_id, &content_str).await?; | |
| 324 | + | } | |
| 325 | + | crate::db::pending_s3_deletions::enqueue_deletions( | |
| 326 | + | &mut *tx, | |
| 327 | + | &[(staging_key.to_string(), bucket.as_str().to_string())], | |
| 328 | + | "scan_promote_staging", | |
| 329 | + | ) | |
| 330 | + | .await?; | |
| 331 | + | tx.commit().await?; | |
| 332 | + | Ok(()) | |
| 333 | + | } | |
| 334 | + | ||
| 259 | 335 | /// Aggregate scan result across all layers | |
| 260 | 336 | #[derive(Debug, Clone)] | |
| 261 | 337 | pub struct ScanResult { |
| @@ -268,6 +268,14 @@ async fn process_job(ctx: &WorkerContext, job: ScanJob) -> Result<(), Box<dyn st | |||
| 268 | 268 | return Err(e); | |
| 269 | 269 | } | |
| 270 | 270 | }; | |
| 271 | + | // Stamp the entity's terminal scan_status. For a Clean staging upload the | |
| 272 | + | // promote inside `run_pipeline_and_decide` already set the key column AND | |
| 273 | + | // `scan_status = 'clean'` in one transaction; this re-stamp is then a | |
| 274 | + | // harmless idempotent write. It is load-bearing, though, for a Clean file | |
| 275 | + | // that was uploaded server-side to a NON-staging key (the build runner's OTA | |
| 276 | + | // artifacts) — there is nothing to promote, so this is the only place its | |
| 277 | + | // status is cleared. (For the gate-less image kinds this is a no-op; their | |
| 278 | + | // row was stamped by the promote or the held-image branch above.) | |
| 271 | 279 | update_entity_status(&ctx.db, kind, target_id, entity_status).await?; | |
| 272 | 280 | ||
| 273 | 281 | // Verdict + duration metrics (Run 20 Observability): quarantine/hold/error | |
| @@ -564,7 +572,36 @@ async fn run_pipeline_and_decide( | |||
| 564 | 572 | // upload's image stays hidden until an admin clears it — the same | |
| 565 | 573 | // fail-closed posture the gated Item/Version/Media kinds already get. | |
| 566 | 574 | // Quarantine never reaches here (it returned above with the row purged). | |
| 567 | - | if kind.is_cdn_served_without_gate() { | |
| 575 | + | // C1 scan-then-promote: a Clean verdict copies the object from its unserved | |
| 576 | + | // staging key to the immutable content key and repoints the entity (gated | |
| 577 | + | // kinds by id, CDN-image kinds by staging key, incl. rebuilding the public | |
| 578 | + | // URL) in one shared step. This is the ONLY place a served key comes into | |
| 579 | + | // existence — the presign handlers can only mint staging keys (the sealed | |
| 580 | + | // generators are `pub(crate)`, callable only from here via `content_key`). | |
| 581 | + | // Fail-safe: a copy/DB error bubbles up so `process_job` leaves the job | |
| 582 | + | // un-done and the entity keeps pointing at the (unserved) staging key; the | |
| 583 | + | // retry re-promotes. | |
| 584 | + | if status == FileScanStatus::Clean && job.s3_key.starts_with("staging/") { | |
| 585 | + | // Client-presigned upload: copy staging -> content key and repoint the | |
| 586 | + | // entity. A Clean file at a non-staging key was uploaded server-side to | |
| 587 | + | // its final key (build-runner OTA) and needs no promote — its status is | |
| 588 | + | // stamped by `update_entity_status` in `process_job`. | |
| 589 | + | super::promote_staging_to_content( | |
| 590 | + | &ctx.db, | |
| 591 | + | backend.as_ref(), | |
| 592 | + | ctx.cdn_base_url.as_deref(), | |
| 593 | + | kind, | |
| 594 | + | file_type, | |
| 595 | + | job.target_id, | |
| 596 | + | job.user_id, | |
| 597 | + | &job.s3_key, | |
| 598 | + | &result.sha256, | |
| 599 | + | bucket, | |
| 600 | + | ) | |
| 601 | + | .await?; | |
| 602 | + | } else if status != FileScanStatus::Clean && kind.is_cdn_served_without_gate() { | |
| 603 | + | // Held/pending image (never promoted): stamp the row keyed on its staging | |
| 604 | + | // key so the fail-closed render gate hides it until an admin clears it. | |
| 568 | 605 | match db::scanning::set_cdn_image_scan_status_by_key(&ctx.db, &job.s3_key, status).await { | |
| 569 | 606 | Ok(n) => tracing::info!( | |
| 570 | 607 | s3_key = %job.s3_key, target_kind = %kind.as_str(), scan_status = %status, rows = n, |
| @@ -530,11 +530,16 @@ impl S3Client { | |||
| 530 | 530 | /// its content-addressed [`content_key`](Self::content_key) and deletes the | |
| 531 | 531 | /// staging object. The upload's extension is carried in the staging key so | |
| 532 | 532 | /// the worker can build the content key without re-reading the entity row. | |
| 533 | - | /// Format: `staging/{uuid}.{ext}`. The random uuid means a replayed presigned | |
| 534 | - | /// PUT can only re-write the (unserved, post-scan-deleted) staging object, | |
| 535 | - | /// never the served content key (ultra-fuzz Run #24 Storage HIGH). | |
| 533 | + | /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment | |
| 534 | + | /// means a replayed presigned PUT can only re-write the (unserved, | |
| 535 | + | /// post-scan-deleted) staging object, never the served content key (ultra-fuzz | |
| 536 | + | /// Run #24 Storage HIGH), and two uploads of the same filename never collide. | |
| 537 | + | /// The original filename is preserved after the uuid so a confirm can recover | |
| 538 | + | /// it (e.g. a version download's suggested name) — `sanitize_filename` strips | |
| 539 | + | /// any `/`, so the name can't add path segments or escape the `staging/` | |
| 540 | + | /// prefix. The extension still rides along for the content key. | |
| 536 | 541 | pub fn generate_staging_key(filename: &str) -> S3Key { | |
| 537 | - | S3Key(format!("staging/{}.{}", uuid::Uuid::new_v4(), extension_for(filename))) | |
| 542 | + | S3Key(format!("staging/{}/{}", uuid::Uuid::new_v4(), sanitize_filename(filename))) | |
| 538 | 543 | } | |
| 539 | 544 | ||
| 540 | 545 | /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's | |
| @@ -895,6 +900,10 @@ impl S3Client { | |||
| 895 | 900 | /// filename can't bloat the key. Content keys carry an extension purely so | |
| 896 | 901 | /// CDN-served objects keep a sensible suffix (content-type sniffing, browser | |
| 897 | 902 | /// "save as"); the hash is the identity, the extension is cosmetic. | |
| 903 | + | // Retained as a tested key-extension utility; `generate_staging_key` now embeds | |
| 904 | + | // the full sanitized filename (which carries the extension) instead of calling | |
| 905 | + | // this, so it has no production caller today. | |
| 906 | + | #[allow(dead_code)] | |
| 898 | 907 | pub(crate) fn extension_for(filename: &str) -> String { | |
| 899 | 908 | let ext: String = std::path::Path::new(filename) | |
| 900 | 909 | .extension() | |
| @@ -909,11 +918,8 @@ pub(crate) fn extension_for(filename: &str) -> String { | |||
| 909 | 918 | } | |
| 910 | 919 | ||
| 911 | 920 | /// The extension segment of a key's basename (the text after the last `.`), or | |
| 912 | - | /// `"bin"`. Lets the scan worker carry a `staging/{uuid}.{ext}` object's | |
| 921 | + | /// `"bin"`. Lets the scan worker's promote step carry a staging object's | |
| 913 | 922 | /// extension onto its content key without re-reading the entity row. | |
| 914 | - | // Consumed by the scan worker's copy-on-clean step (Phase 2 of the | |
| 915 | - | // content-addressed scan-integrity change); tested now as a Phase 1 foundation. | |
| 916 | - | #[allow(dead_code)] | |
| 917 | 923 | pub(crate) fn key_extension(key: &str) -> &str { | |
| 918 | 924 | key.rsplit('/') | |
| 919 | 925 | .next() | |
| @@ -1689,3 +1695,83 @@ mod delete_seal_guard { | |||
| 1689 | 1695 | } | |
| 1690 | 1696 | } | |
| 1691 | 1697 | } | |
| 1698 | + | ||
| 1699 | + | /// C1 scan-then-promote seal: route handlers must never mint a *served* S3 key. | |
| 1700 | + | /// | |
| 1701 | + | /// A presigned client upload can only ever land at a `staging/{uuid}` key | |
| 1702 | + | /// ([`S3Client::generate_staging_key`]); the served, content-addressed key | |
| 1703 | + | /// ([`S3Client::content_key`]) is created in exactly one place — the scan | |
| 1704 | + | /// worker's promote step, after a Clean verdict — so the bytes a buyer is served | |
| 1705 | + | /// are provably the bytes that were scanned. The mutable-served-key class (a | |
| 1706 | + | /// presign minting `{user}/{item}/type/filename`, then the owner re-PUTting to it | |
| 1707 | + | /// after it goes Clean) is what this closes. | |
| 1708 | + | /// | |
| 1709 | + | /// This guard fails the build if any file under `src/routes/` names a served-key | |
| 1710 | + | /// generator or `content_key`. It is stronger than `pub(crate)` visibility — | |
| 1711 | + | /// route code lives in the same crate, so `pub(crate)` would not stop it from | |
| 1712 | + | /// calling these — and it is the same grep-proof discipline as the delete seal | |
| 1713 | + | /// above. (The build runner uploads OTA artifacts server-side to a deterministic | |
| 1714 | + | /// key via `generate_ota_artifact_key`; it lives outside `src/routes/`, so it is | |
| 1715 | + | /// legitimately unaffected.) | |
| 1716 | + | #[cfg(test)] | |
| 1717 | + | mod served_key_seal_guard { | |
| 1718 | + | use std::path::Path; | |
| 1719 | + | ||
| 1720 | + | #[test] | |
| 1721 | + | fn routes_never_mint_served_keys() { | |
| 1722 | + | let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes"); | |
| 1723 | + | // Every served-key generator, plus the content-key minter. `staging` | |
| 1724 | + | // keys are the ONLY key a route may mint, so `generate_staging_key` is | |
| 1725 | + | // deliberately absent from this list. | |
| 1726 | + | // Anchored to the `S3Client::` call prefix so an unrelated `generate_key` | |
| 1727 | + | // (e.g. `license_keys::generate_key`, `helpers::generate_key_code`) is not | |
| 1728 | + | // a false positive — only the storage generators are S3Client methods. | |
| 1729 | + | const FORBIDDEN: &[&str] = &[ | |
| 1730 | + | "S3Client::generate_key(", | |
| 1731 | + | "S3Client::generate_version_key(", | |
| 1732 | + | "S3Client::generate_insertion_key(", | |
| 1733 | + | "S3Client::generate_media_key(", | |
| 1734 | + | "S3Client::generate_project_image_key(", | |
| 1735 | + | "S3Client::generate_ota_artifact_key(", | |
| 1736 | + | "S3Client::generate_item_gallery_key(", | |
| 1737 | + | "S3Client::generate_project_gallery_key(", | |
| 1738 | + | "S3Client::content_key(", | |
| 1739 | + | ]; | |
| 1740 | + | let mut offenders = Vec::new(); | |
| 1741 | + | walk(&routes_dir, &mut |path, contents| { | |
| 1742 | + | for (i, line) in contents.lines().enumerate() { | |
| 1743 | + | if line.trim_start().starts_with("//") { | |
| 1744 | + | continue; | |
| 1745 | + | } | |
| 1746 | + | for needle in FORBIDDEN { | |
| 1747 | + | if line.contains(needle) { | |
| 1748 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 1749 | + | } | |
| 1750 | + | } | |
| 1751 | + | } | |
| 1752 | + | }); | |
| 1753 | + | assert!( | |
| 1754 | + | offenders.is_empty(), | |
| 1755 | + | "C1 seal violated — route handlers must presign only `generate_staging_key`; \ | |
| 1756 | + | the served/content key is minted solely by the scan worker's promote step. \ | |
| 1757 | + | Offending lines:\n{}", | |
| 1758 | + | offenders.join("\n") | |
| 1759 | + | ); | |
| 1760 | + | } | |
| 1761 | + | ||
| 1762 | + | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 1763 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 1764 | + | return; | |
| 1765 | + | }; | |
| 1766 | + | for entry in entries.flatten() { | |
| 1767 | + | let path = entry.path(); | |
| 1768 | + | if path.is_dir() { | |
| 1769 | + | walk(&path, f); | |
| 1770 | + | } else if path.extension().is_some_and(|e| e == "rs") | |
| 1771 | + | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 1772 | + | { | |
| 1773 | + | f(&path, &contents); | |
| 1774 | + | } | |
| 1775 | + | } | |
| 1776 | + | } | |
| 1777 | + | } |
| @@ -12,6 +12,19 @@ async fn setup_creator(h: &mut TestHarness) -> String { | |||
| 12 | 12 | user_id.to_string() | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | /// Record a pending-upload row for a test-forged key. Under scan-then-promote the | |
| 16 | + | /// confirm proves ownership via `pending_uploads` (a `staging/{uuid}` key has no | |
| 17 | + | /// user in its path), so a test that fabricates a key + object must also register | |
| 18 | + | /// it as if presign had — otherwise the ownership gate correctly rejects it. | |
| 19 | + | async fn record_pending(h: &TestHarness, user_id: &str, s3_key: &str) { | |
| 20 | + | sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1::uuid, $2, 'main') ON CONFLICT DO NOTHING") | |
| 21 | + | .bind(user_id) | |
| 22 | + | .bind(s3_key) | |
| 23 | + | .execute(&h.db) | |
| 24 | + | .await | |
| 25 | + | .expect("record pending upload"); | |
| 26 | + | } | |
| 27 | + | ||
| 15 | 28 | #[tokio::test] | |
| 16 | 29 | async fn presign_requires_auth() { | |
| 17 | 30 | let mut h = TestHarness::with_storage().await; | |
| @@ -95,9 +108,11 @@ async fn confirm_with_object_succeeds() { | |||
| 95 | 108 | let mut h = TestHarness::with_storage().await; | |
| 96 | 109 | let user_id = setup_creator(&mut h).await; | |
| 97 | 110 | ||
| 98 | - | // Pre-populate storage with a fake object (key must match user_id prefix) | |
| 111 | + | // Pre-populate storage with a fake object and record it as a pending upload | |
| 112 | + | // (the confirm now proves ownership via pending_uploads, not a key prefix). | |
| 99 | 113 | let s3_key = format!("{}/insertions/intro.mp3", user_id); | |
| 100 | 114 | h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); | |
| 115 | + | record_pending(&h, &user_id, &s3_key).await; | |
| 101 | 116 | ||
| 102 | 117 | let resp = h | |
| 103 | 118 | .client | |
| @@ -207,6 +222,7 @@ async fn presign_valid_video_succeeds() { | |||
| 207 | 222 | async fn confirm_clip(h: &mut TestHarness, user_id: &str, file: &str, mime: &str) -> Value { | |
| 208 | 223 | let s3_key = format!("{}/insertions/{}", user_id, file); | |
| 209 | 224 | h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); | |
| 225 | + | record_pending(h, user_id, &s3_key).await; | |
| 210 | 226 | let resp = h | |
| 211 | 227 | .client | |
| 212 | 228 | .post_json( |
| @@ -260,7 +260,10 @@ async fn gallery_project_target_inserts_row() { | |||
| 260 | 260 | .unwrap(); | |
| 261 | 261 | assert_eq!(count, 1); | |
| 262 | 262 | assert_eq!(db_key, s3_key); | |
| 263 | - | assert!(s3_key.starts_with(&format!("projects/{}/gallery/", project_id))); | |
| 263 | + | // No scanner in this harness, so the row still holds the unserved staging key | |
| 264 | + | // (a Clean scan would later promote it to a content key). Presign hands out | |
| 265 | + | // `staging/{uuid}/{filename}`, never the old `projects/{id}/gallery/…` key. | |
| 266 | + | assert!(s3_key.starts_with("staging/"), "gallery presign must return a staging key: {s3_key}"); | |
| 264 | 267 | assert_eq!(storage_used(&h, &user_id).await, 500); | |
| 265 | 268 | } | |
| 266 | 269 |
| @@ -541,15 +541,24 @@ async fn media_duplicate_confirm_rejects_without_deleting_live_object() { | |||
| 541 | 541 | .bind(&user_id).fetch_one(&h.db).await.unwrap(); | |
| 542 | 542 | assert_eq!(used_after_first, TINY_PNG.len() as i64); | |
| 543 | 543 | ||
| 544 | - | // Re-confirm the SAME key. Rejected as a duplicate ... | |
| 545 | - | let resp = h.client.post_json("/api/media/confirm", &confirm_body.to_string()).await; | |
| 544 | + | // A genuine duplicate under scan-then-promote: presign a SECOND upload of the | |
| 545 | + | // same folder+filename (a fresh staging key, so it passes the ownership gate), | |
| 546 | + | // then confirm. It collides on the (user, folder, filename) unique index and | |
| 547 | + | // is rejected as "already exists". | |
| 548 | + | let presign2 = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""}); | |
| 549 | + | let resp = h.client.post_json("/api/media/presign", &presign2.to_string()).await; | |
| 550 | + | assert!(resp.status.is_success(), "second presign failed: {}", resp.text); | |
| 551 | + | let s3_key2 = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string(); | |
| 552 | + | h.storage.as_ref().unwrap().put(&s3_key2, TINY_PNG.to_vec()); | |
| 553 | + | let confirm2 = json!({"s3_key": s3_key2, "file_name": "pic.png", "content_type": "image/png", "folder": ""}); | |
| 554 | + | let resp = h.client.post_json("/api/media/confirm", &confirm2.to_string()).await; | |
| 546 | 555 | assert!(resp.status.is_client_error(), "duplicate confirm should be rejected: {} {}", resp.status, resp.text); | |
| 547 | 556 | assert!(resp.text.contains("already exists"), "rejection should name the collision: {}", resp.text); | |
| 548 | 557 | ||
| 549 | - | // ... but the live object the committed row points at must SURVIVE (the HIGH). | |
| 558 | + | // ... but the FIRST upload's live object must SURVIVE (the HIGH). | |
| 550 | 559 | assert!( | |
| 551 | 560 | h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(), | |
| 552 | - | "duplicate confirm must NOT delete the live object" | |
| 561 | + | "duplicate confirm must NOT delete the first upload's live object" | |
| 553 | 562 | ); | |
| 554 | 563 | // ... and the rolled-back tx must not have double-charged storage. | |
| 555 | 564 | let used_after_second: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") |
| @@ -4,6 +4,7 @@ use crate::harness::TestHarness; | |||
| 4 | 4 | use serde_json::{json, Value}; | |
| 5 | 5 | ||
| 6 | 6 | use makenotwork::db::UserId; | |
| 7 | + | use makenotwork::storage::StorageBackend; | |
| 7 | 8 | ||
| 8 | 9 | /// Helper: set up a trusted creator with a project and audio item. | |
| 9 | 10 | async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String) { | |
| @@ -49,7 +50,9 @@ async fn confirm_upload_clean_file_passes() { | |||
| 49 | 50 | // completion before asserting final state. | |
| 50 | 51 | h.drain_scan_jobs().await; | |
| 51 | 52 | ||
| 52 | - | // Verify audio_s3_key was set in DB | |
| 53 | + | // C1 scan-then-promote: a Clean scan copies the object from its staging key | |
| 54 | + | // to a content-addressed served key and repoints the row. audio_s3_key must | |
| 55 | + | // NO LONGER be the staging key — it is now `{user}/c/{sha256}.mp3`. | |
| 53 | 56 | let db_key: Option<String> = sqlx::query_scalar( | |
| 54 | 57 | "SELECT audio_s3_key FROM items WHERE id = $1::uuid", | |
| 55 | 58 | ) | |
| @@ -57,7 +60,16 @@ async fn confirm_upload_clean_file_passes() { | |||
| 57 | 60 | .fetch_one(&h.db) | |
| 58 | 61 | .await | |
| 59 | 62 | .unwrap(); | |
| 60 | - | assert_eq!(db_key.as_deref(), Some(s3_key.as_str())); | |
| 63 | + | let db_key = db_key.expect("audio_s3_key set"); | |
| 64 | + | assert_ne!(db_key, s3_key, "clean scan must promote off the staging key"); | |
| 65 | + | assert!(!db_key.starts_with("staging/"), "promoted key must not be a staging key: {db_key}"); | |
| 66 | + | assert!(db_key.contains("/c/"), "promoted key must be content-addressed: {db_key}"); | |
| 67 | + | assert!(db_key.ends_with(".mp3"), "content key keeps the extension: {db_key}"); | |
| 68 | + | ||
| 69 | + | // The served object exists at the content key; the staging object is gone | |
| 70 | + | // (enqueued for durable deletion after the promote copy). | |
| 71 | + | let store = h.storage.as_ref().unwrap(); | |
| 72 | + | assert!(store.object_exists(&db_key).await.unwrap(), "content object must exist after promote"); | |
| 61 | 73 | ||
| 62 | 74 | // Verify scan_status is clean | |
| 63 | 75 | let scan_status: String = sqlx::query_scalar( | |
| @@ -70,6 +82,59 @@ async fn confirm_upload_clean_file_passes() { | |||
| 70 | 82 | assert_eq!(scan_status, "clean"); | |
| 71 | 83 | } | |
| 72 | 84 | ||
| 85 | + | /// The C1 invariant end-to-end: after a Clean scan promotes an upload to its | |
| 86 | + | /// content key, a creator re-PUT to the (still-known) staging URL cannot change | |
| 87 | + | /// the bytes a buyer is served. The served key is the content key; the staging | |
| 88 | + | /// object is a dead end. | |
| 89 | + | #[tokio::test] | |
| 90 | + | async fn repost_to_staging_after_clean_cannot_change_served_bytes() { | |
| 91 | + | let mut h = TestHarness::with_storage_and_scanner().await; | |
| 92 | + | let (_project_id, item_id) = setup_creator_with_item(&mut h).await; | |
| 93 | + | ||
| 94 | + | // Presign → the client only ever holds a presign to the staging key. | |
| 95 | + | let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "song.mp3", "content_type": "audio/mpeg"}); | |
| 96 | + | let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await; | |
| 97 | + | assert!(resp.status.is_success(), "presign: {}", resp.text); | |
| 98 | + | let staging_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string(); | |
| 99 | + | assert!(staging_key.starts_with("staging/")); | |
| 100 | + | ||
| 101 | + | // Upload clean bytes and confirm. | |
| 102 | + | let mut clean = b"ID3".to_vec(); | |
| 103 | + | clean.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); | |
| 104 | + | clean.extend_from_slice(&[0xAAu8; 200]); | |
| 105 | + | h.storage.as_ref().unwrap().put(&staging_key, clean.clone()); | |
| 106 | + | let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": staging_key}); | |
| 107 | + | let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await; | |
| 108 | + | assert!(resp.status.is_success(), "confirm: {}", resp.text); | |
| 109 | + | h.drain_scan_jobs().await; | |
| 110 | + | ||
| 111 | + | // The row now serves a content key, NOT the staging key. | |
| 112 | + | let served_key: String = sqlx::query_scalar::<_, Option<String>>("SELECT audio_s3_key FROM items WHERE id = $1::uuid") | |
| 113 | + | .bind(&item_id) | |
| 114 | + | .fetch_one(&h.db) | |
| 115 | + | .await | |
| 116 | + | .unwrap() | |
| 117 | + | .expect("audio promoted"); | |
| 118 | + | assert!(served_key.contains("/c/"), "served key must be content-addressed: {served_key}"); | |
| 119 | + | assert_ne!(served_key, staging_key); | |
| 120 | + | ||
| 121 | + | let store = h.storage.as_ref().unwrap(); | |
| 122 | + | let served_before = store.download_object(&served_key).await.unwrap(); | |
| 123 | + | assert_eq!(served_before, clean, "the content object holds the scanned bytes"); | |
| 124 | + | ||
| 125 | + | // The attack: re-PUT malware to the staging key the creator still holds a | |
| 126 | + | // presign for. This is the exact move the old mutable-served-key design let | |
| 127 | + | // a creator use to swap post-scan bytes. | |
| 128 | + | let malware = vec![0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00]; | |
| 129 | + | store.put(&staging_key, malware.clone()); | |
| 130 | + | ||
| 131 | + | // The served key is untouched: a buyer still gets the scanned bytes. The | |
| 132 | + | // staging object is irrelevant — it is not what the row serves. | |
| 133 | + | let served_after = store.download_object(&served_key).await.unwrap(); | |
| 134 | + | assert_eq!(served_after, clean, "re-PUT to the staging key must NOT change the served bytes"); | |
| 135 | + | assert_ne!(served_after, malware); | |
| 136 | + | } | |
| 137 | + | ||
| 73 | 138 | #[tokio::test] | |
| 74 | 139 | async fn confirm_upload_bad_magic_quarantined() { | |
| 75 | 140 | let mut h = TestHarness::with_storage_and_scanner().await; | |
| @@ -167,7 +232,12 @@ async fn quarantined_cover_nulls_columns_but_keeps_published_track() { | |||
| 167 | 232 | .await | |
| 168 | 233 | .expect("the item row must still exist — quarantining a cover must not delete the track"); | |
| 169 | 234 | assert_eq!(status, "clean", "a quarantined cover must not touch the track's gate status"); | |
| 170 | - | assert_eq!(audio.as_deref(), Some(audio_key.as_str()), "the audio track must be preserved"); | |
| 235 | + | // The audio track was clean-scanned, so it was promoted off its staging key to | |
| 236 | + | // a content-addressed key — the point is it survives the cover quarantine | |
| 237 | + | // (non-null, promoted, still gated Clean), not that it keeps the staging name. | |
| 238 | + | let audio = audio.expect("the audio track must be preserved"); | |
| 239 | + | assert_ne!(audio, audio_key, "the audio track must have been promoted, not delisted"); | |
| 240 | + | assert!(audio.contains("/c/"), "the surviving track must be content-addressed: {audio}"); | |
| 171 | 241 | assert_eq!(ck, None, "the quarantined cover key must be NULLed"); | |
| 172 | 242 | assert_eq!(cu, None, "the quarantined cover URL must be NULLed so it stops rendering"); | |
| 173 | 243 | } |
| @@ -35,7 +35,12 @@ async fn presign_upload_audio() { | |||
| 35 | 35 | ||
| 36 | 36 | let data: Value = resp.json(); | |
| 37 | 37 | assert!(data["upload_url"].as_str().unwrap().starts_with("http://test-storage/")); | |
| 38 | - | assert!(data["s3_key"].as_str().unwrap().contains("/audio/episode.mp3")); | |
| 38 | + | // Scan-then-promote: presign hands out an unserved staging key | |
| 39 | + | // (`staging/{uuid}/{filename}`), never the served key. The served, | |
| 40 | + | // content-addressed key is minted by the scan worker on a Clean verdict. | |
| 41 | + | let key = data["s3_key"].as_str().unwrap(); | |
| 42 | + | assert!(key.starts_with("staging/"), "presign must return a staging key: {key}"); | |
| 43 | + | assert!(key.ends_with("/episode.mp3"), "staging key preserves the filename: {key}"); | |
| 39 | 44 | assert_eq!(data["expires_in"], 3600); | |
| 40 | 45 | } | |
| 41 | 46 |