max / makenotwork
3 files changed,
+125 insertions,
-27 deletions
| @@ -372,14 +372,14 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 372 | 372 | let row: (i64, i64, i64, i64, i64, i64, i64) = sqlx::query_as( | |
| 373 | 373 | r#" | |
| 374 | 374 | WITH audio_bytes AS ( | |
| 375 | - | SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total | |
| 375 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(i.audio_file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total | |
| 376 | 376 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 377 | 377 | WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL | |
| 378 | 378 | ), | |
| 379 | 379 | cover_bytes AS ( | |
| 380 | - | SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) | |
| 380 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(i.cover_file_size_bytes), 9223372036854775807))::BIGINT, 0) | |
| 381 | 381 | + COALESCE(( | |
| 382 | - | SELECT SUM(p.cover_image_size_bytes)::BIGINT | |
| 382 | + | SELECT GREATEST(0, LEAST(SUM(p.cover_image_size_bytes), 9223372036854775807))::BIGINT | |
| 383 | 383 | FROM projects p | |
| 384 | 384 | WHERE p.user_id = $1 AND p.cover_image_size_bytes IS NOT NULL | |
| 385 | 385 | ), 0) AS total | |
| @@ -387,33 +387,33 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 387 | 387 | WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL | |
| 388 | 388 | ), | |
| 389 | 389 | version_bytes AS ( | |
| 390 | - | SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total | |
| 390 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(v.file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total | |
| 391 | 391 | FROM versions v | |
| 392 | 392 | JOIN items i ON v.item_id = i.id | |
| 393 | 393 | JOIN projects p ON i.project_id = p.id | |
| 394 | 394 | WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL | |
| 395 | 395 | ), | |
| 396 | 396 | insertion_bytes AS ( | |
| 397 | - | SELECT COALESCE(SUM(file_size)::BIGINT, 0) AS total | |
| 397 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(file_size), 9223372036854775807))::BIGINT, 0) AS total | |
| 398 | 398 | FROM content_insertions WHERE user_id = $1 | |
| 399 | 399 | ), | |
| 400 | 400 | video_bytes AS ( | |
| 401 | - | SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) AS total | |
| 401 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(i.video_file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total | |
| 402 | 402 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 403 | 403 | WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL | |
| 404 | 404 | ), | |
| 405 | 405 | media_bytes AS ( | |
| 406 | - | SELECT COALESCE(SUM(file_size_bytes)::BIGINT, 0) AS total | |
| 406 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total | |
| 407 | 407 | FROM media_files WHERE user_id = $1 | |
| 408 | 408 | ), | |
| 409 | 409 | gallery_bytes AS ( | |
| 410 | 410 | SELECT COALESCE(( | |
| 411 | - | SELECT SUM(ii.file_size_bytes)::BIGINT | |
| 411 | + | SELECT GREATEST(0, LEAST(SUM(ii.file_size_bytes), 9223372036854775807))::BIGINT | |
| 412 | 412 | FROM item_images ii JOIN items i ON ii.item_id = i.id JOIN projects p ON i.project_id = p.id | |
| 413 | 413 | WHERE p.user_id = $1 | |
| 414 | 414 | ), 0) | |
| 415 | 415 | + COALESCE(( | |
| 416 | - | SELECT SUM(pi.file_size_bytes)::BIGINT | |
| 416 | + | SELECT GREATEST(0, LEAST(SUM(pi.file_size_bytes), 9223372036854775807))::BIGINT | |
| 417 | 417 | FROM project_images pi JOIN projects p ON pi.project_id = p.id | |
| 418 | 418 | WHERE p.user_id = $1 | |
| 419 | 419 | ), 0) AS total | |
| @@ -571,31 +571,31 @@ pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> { | |||
| 571 | 571 | + COALESCE(project_gallery.total, 0) AS total | |
| 572 | 572 | FROM users u | |
| 573 | 573 | LEFT JOIN LATERAL ( | |
| 574 | - | SELECT SUM(i.audio_file_size_bytes)::BIGINT AS total | |
| 574 | + | SELECT GREATEST(0, LEAST(SUM(i.audio_file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 575 | 575 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 576 | 576 | WHERE p.user_id = u.id AND i.audio_file_size_bytes IS NOT NULL | |
| 577 | 577 | ) audio ON true | |
| 578 | 578 | LEFT JOIN LATERAL ( | |
| 579 | - | SELECT SUM(i.cover_file_size_bytes)::BIGINT AS total | |
| 579 | + | SELECT GREATEST(0, LEAST(SUM(i.cover_file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 580 | 580 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 581 | 581 | WHERE p.user_id = u.id AND i.cover_file_size_bytes IS NOT NULL | |
| 582 | 582 | ) cover ON true | |
| 583 | 583 | LEFT JOIN LATERAL ( | |
| 584 | - | SELECT SUM(i.video_file_size_bytes)::BIGINT AS total | |
| 584 | + | SELECT GREATEST(0, LEAST(SUM(i.video_file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 585 | 585 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 586 | 586 | WHERE p.user_id = u.id AND i.video_file_size_bytes IS NOT NULL | |
| 587 | 587 | ) video ON true | |
| 588 | 588 | LEFT JOIN LATERAL ( | |
| 589 | - | SELECT SUM(v.file_size_bytes)::BIGINT AS total | |
| 589 | + | SELECT GREATEST(0, LEAST(SUM(v.file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 590 | 590 | FROM versions v JOIN items i ON v.item_id = i.id JOIN projects p ON i.project_id = p.id | |
| 591 | 591 | WHERE p.user_id = u.id AND v.file_size_bytes IS NOT NULL | |
| 592 | 592 | ) versions ON true | |
| 593 | 593 | LEFT JOIN LATERAL ( | |
| 594 | - | SELECT SUM(ci.file_size)::BIGINT AS total | |
| 594 | + | SELECT GREATEST(0, LEAST(SUM(ci.file_size), 9223372036854775807))::BIGINT AS total | |
| 595 | 595 | FROM content_insertions ci WHERE ci.user_id = u.id | |
| 596 | 596 | ) insertions ON true | |
| 597 | 597 | LEFT JOIN LATERAL ( | |
| 598 | - | SELECT SUM(mf.file_size_bytes)::BIGINT AS total | |
| 598 | + | SELECT GREATEST(0, LEAST(SUM(mf.file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 599 | 599 | FROM media_files mf WHERE mf.user_id = u.id | |
| 600 | 600 | ) media ON true | |
| 601 | 601 | -- Project cover images charge storage at confirm but live in their own | |
| @@ -606,17 +606,17 @@ pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> { | |||
| 606 | 606 | -- gallery/project-cover charge and the counter oscillated week to week | |
| 607 | 607 | -- (Run #18 Storage B1). Reconcile them from the same rows the charge writes. | |
| 608 | 608 | LEFT JOIN LATERAL ( | |
| 609 | - | SELECT SUM(p.cover_image_size_bytes)::BIGINT AS total | |
| 609 | + | SELECT GREATEST(0, LEAST(SUM(p.cover_image_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 610 | 610 | FROM projects p | |
| 611 | 611 | WHERE p.user_id = u.id AND p.cover_image_size_bytes IS NOT NULL | |
| 612 | 612 | ) project_cover ON true | |
| 613 | 613 | LEFT JOIN LATERAL ( | |
| 614 | - | SELECT SUM(ii.file_size_bytes)::BIGINT AS total | |
| 614 | + | SELECT GREATEST(0, LEAST(SUM(ii.file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 615 | 615 | FROM item_images ii JOIN items i ON ii.item_id = i.id JOIN projects p ON i.project_id = p.id | |
| 616 | 616 | WHERE p.user_id = u.id | |
| 617 | 617 | ) item_gallery ON true | |
| 618 | 618 | LEFT JOIN LATERAL ( | |
| 619 | - | SELECT SUM(pi.file_size_bytes)::BIGINT AS total | |
| 619 | + | SELECT GREATEST(0, LEAST(SUM(pi.file_size_bytes), 9223372036854775807))::BIGINT AS total | |
| 620 | 620 | FROM project_images pi JOIN projects p ON pi.project_id = p.id | |
| 621 | 621 | WHERE p.user_id = u.id | |
| 622 | 622 | ) project_gallery ON true | |
| @@ -840,7 +840,7 @@ pub async fn get_effective_max_file_bytes( | |||
| 840 | 840 | pub async fn get_user_content_size(pool: &PgPool, user_id: UserId) -> Result<i64> { | |
| 841 | 841 | let version_size: i64 = sqlx::query_scalar( | |
| 842 | 842 | r#" | |
| 843 | - | SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) | |
| 843 | + | SELECT COALESCE(GREATEST(0, LEAST(SUM(v.file_size_bytes), 9223372036854775807))::BIGINT, 0) | |
| 844 | 844 | FROM versions v | |
| 845 | 845 | JOIN items i ON v.item_id = i.id | |
| 846 | 846 | JOIN projects p ON i.project_id = p.id | |
| @@ -852,7 +852,7 @@ pub async fn get_user_content_size(pool: &PgPool, user_id: UserId) -> Result<i64 | |||
| 852 | 852 | .await?; | |
| 853 | 853 | ||
| 854 | 854 | let insertion_size: i64 = sqlx::query_scalar( | |
| 855 | - | "SELECT COALESCE(SUM(file_size)::BIGINT, 0) FROM content_insertions WHERE user_id = $1", | |
| 855 | + | "SELECT COALESCE(GREATEST(0, LEAST(SUM(file_size), 9223372036854775807))::BIGINT, 0) FROM content_insertions WHERE user_id = $1", | |
| 856 | 856 | ) | |
| 857 | 857 | .bind(user_id) | |
| 858 | 858 | .fetch_one(pool) |
| @@ -44,8 +44,9 @@ pub struct MediaConfirmRequest { | |||
| 44 | 44 | pub s3_key: String, | |
| 45 | 45 | pub file_name: String, | |
| 46 | 46 | pub content_type: String, | |
| 47 | - | #[serde(default)] | |
| 48 | - | pub folder: 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. | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | 52 | #[derive(Debug, Deserialize)] | |
| @@ -229,6 +230,40 @@ pub(super) async fn media_confirm( | |||
| 229 | 230 | ))); | |
| 230 | 231 | } | |
| 231 | 232 | ||
| 233 | + | // Reconcile the real media category against the declared content_type before | |
| 234 | + | // tier enforcement. The declared type is client-controlled — it is bound into | |
| 235 | + | // the presigned PUT and merely echoed back by S3 metadata — so trusting it | |
| 236 | + | // lets a video be declared `image/png` and dodge the BigFiles+ video-tier | |
| 237 | + | // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is | |
| 238 | + | // detectably a different audio/visual category than declared, reject + orphan. | |
| 239 | + | { | |
| 240 | + | let mut stream = s3.download_stream(&req.s3_key).await?; | |
| 241 | + | let mut head: Vec<u8> = Vec::with_capacity(4096); | |
| 242 | + | loop { | |
| 243 | + | if head.len() >= 4096 { | |
| 244 | + | break; | |
| 245 | + | } | |
| 246 | + | match stream.try_next().await { | |
| 247 | + | Ok(Some(chunk)) => head.extend_from_slice(&chunk), | |
| 248 | + | Ok(None) => break, | |
| 249 | + | Err(e) => return Err(AppError::Storage(format!("read media header from S3: {e}"))), | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | let detected = infer::get(&head).and_then(|kind| match kind.matcher_type() { | |
| 253 | + | infer::MatcherType::Image => Some("image"), | |
| 254 | + | infer::MatcherType::Video => Some("video"), | |
| 255 | + | _ => None, | |
| 256 | + | }); | |
| 257 | + | if let Some(real) = detected | |
| 258 | + | && real != media_type | |
| 259 | + | { | |
| 260 | + | super::enqueue_s3_orphan(&state.db, &req.s3_key, "media_content_type_mismatch").await; | |
| 261 | + | return Err(AppError::BadRequest(format!( | |
| 262 | + | "Uploaded file is a {real}, but was declared as a {media_type}." | |
| 263 | + | ))); | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 232 | 267 | // Tier enforcement | |
| 233 | 268 | let max_storage = match db::creator_tiers::check_upload_allowed( | |
| 234 | 269 | &state.db, user.id, file_type, file_size_bytes, | |
| @@ -242,11 +277,20 @@ pub(super) async fn media_confirm( | |||
| 242 | 277 | } | |
| 243 | 278 | }; | |
| 244 | 279 | ||
| 245 | - | let folder = sanitize_folder(&req.folder); | |
| 246 | - | // Use the SAME sanitizer generate_media_key used at presign so the stored | |
| 247 | - | // filename matches the key's tail (incl. the empty-basename fallback). A | |
| 248 | - | // re-derived inline filter diverged on names like "( ).png" (Run #18 B9). | |
| 249 | - | let safe_filename = crate::storage::sanitize_filename(&req.file_name); | |
| 280 | + | // Derive folder + filename from the validated S3 key, not the separately | |
| 281 | + | // supplied req.folder / req.file_name (which can disagree with the key the | |
| 282 | + | // object actually lives under — Run #22 Storage MED: the unique index then | |
| 283 | + | // guards a name that doesn't match the object). The key was built at presign | |
| 284 | + | // as `{user}/media/[{folder}/]{filename}` with these same sanitizers, so its | |
| 285 | + | // tail is authoritative and already sanitized. | |
| 286 | + | let key_tail = req.s3_key.strip_prefix(&expected_prefix).unwrap_or(""); | |
| 287 | + | let (folder, safe_filename) = match key_tail.rsplit_once('/') { | |
| 288 | + | Some((f, name)) => (f.to_string(), name.to_string()), | |
| 289 | + | None => (String::new(), key_tail.to_string()), | |
| 290 | + | }; | |
| 291 | + | if safe_filename.is_empty() { | |
| 292 | + | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 293 | + | } | |
| 250 | 294 | ||
| 251 | 295 | // Wrap storage credit + pending_uploads clear + media_files INSERT in a | |
| 252 | 296 | // single transaction. The Run #5 audit flagged the previous non-atomic |
| @@ -82,6 +82,60 @@ async fn upload_media( | |||
| 82 | 82 | (s3_key, file_id) | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | /// Minimal MP4 header — `ftyp` box with the `isom` brand. `infer` classifies | |
| 86 | + | /// this as a video, regardless of the declared content type. | |
| 87 | + | const TINY_MP4: &[u8] = &[ | |
| 88 | + | 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, // size + "ftyp" | |
| 89 | + | 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00, // "isom" + minor version | |
| 90 | + | 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32, // compatible brands | |
| 91 | + | ]; | |
| 92 | + | ||
| 93 | + | // --------------------------------------------------------------------------- | |
| 94 | + | // A video declared as an image is rejected at confirm (tier-gate evasion) | |
| 95 | + | // --------------------------------------------------------------------------- | |
| 96 | + | ||
| 97 | + | #[tokio::test] | |
| 98 | + | async fn video_declared_as_image_is_rejected_at_confirm() { | |
| 99 | + | // Run #22 Storage MED: the declared content_type is client-controlled. A | |
| 100 | + | // video uploaded under an image/png declaration would dodge the BigFiles+ | |
| 101 | + | // video-tier gate. The confirm-time byte sniff must catch and reject it. | |
| 102 | + | let mut h = TestHarness::with_storage().await; | |
| 103 | + | setup_basic_creator(&mut h, "ctevasion").await; | |
| 104 | + | ||
| 105 | + | let body = json!({ | |
| 106 | + | "file_name": "sneaky.png", | |
| 107 | + | "content_type": "image/png", | |
| 108 | + | "folder": "clips", | |
| 109 | + | }); | |
| 110 | + | let resp = h.client.post_json("/api/media/presign", &body.to_string()).await; | |
| 111 | + | assert!(resp.status.is_success(), "image presign should succeed: {}", resp.text); | |
| 112 | + | let presign: Value = resp.json(); | |
| 113 | + | let s3_key = presign["s3_key"].as_str().unwrap().to_string(); | |
| 114 | + | ||
| 115 | + | // Upload actual MP4 bytes under the image key. | |
| 116 | + | h.storage.as_ref().unwrap().put(&s3_key, TINY_MP4.to_vec()); | |
| 117 | + | ||
| 118 | + | let body = json!({ | |
| 119 | + | "s3_key": s3_key, | |
| 120 | + | "file_name": "sneaky.png", | |
| 121 | + | "content_type": "image/png", | |
| 122 | + | "folder": "clips", | |
| 123 | + | }); | |
| 124 | + | let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await; | |
| 125 | + | assert!( | |
| 126 | + | !resp.status.is_success(), | |
| 127 | + | "a video declared as an image must be rejected at confirm, got: {} {}", | |
| 128 | + | resp.status, resp.text | |
| 129 | + | ); | |
| 130 | + | ||
| 131 | + | // Nothing was recorded in the library. | |
| 132 | + | let list: Value = h.client.get("/api/media?folder=clips").await.json(); | |
| 133 | + | assert!( | |
| 134 | + | list["files"].as_array().map(|f| f.is_empty()).unwrap_or(true), | |
| 135 | + | "rejected upload must not appear in the media library" | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 85 | 139 | // --------------------------------------------------------------------------- | |
| 86 | 140 | // Image upload on Basic tier succeeds (images bypass tier check like covers) | |
| 87 | 141 | // --------------------------------------------------------------------------- |