//! Media library upload, listing, and deletion handlers. //! //! Provides a user-scoped media library for embedding images and videos //! in markdown content (item bodies, sections, blog posts). Files are //! stored in S3 under `{user_id}/media/{folder}/{filename}` and served //! via `cdn.makenot.work`. use axum::{ Json, extract::{Path, Query, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::{ AppStorage, Scanning, auth::AuthUser, config::Config, db::{self, MediaFileId}, error::{AppError, Result, ResultExt}, storage::{CACHE_CONTROL_IMMUTABLE, FileType, S3Client, sanitize_filename, sanitize_folder}, }; use super::{CommitTarget, ConfirmUploadResponse, PresignUploadResponse, commit_upload}; // Request / Response Types #[derive(Debug, Deserialize)] pub(crate) struct MediaPresignRequest { pub file_name: String, pub content_type: String, #[serde(default)] pub folder: String, /// Optional declared size; when present it is signed into the presigned /// URL's `Content-Length` so S3 rejects oversized PUTs at the protocol /// level (media video is the largest upload class, up to 20 GB). #[serde(default)] pub file_size_bytes: Option, } #[derive(Debug, Deserialize)] pub(crate) struct MediaConfirmRequest { pub s3_key: String, pub file_name: String, pub content_type: String, // The logical library name (folder + filename) is carried on the confirm. // Under scan-then-promote the physical key is a content hash that no longer // encodes the name, so there is nothing in the key for a client-supplied // folder to disagree with (the Run #22 mismatch concern is gone), the // `(user_id, folder, filename)` unique index guards the logical namespace, // decoupled from the content-addressed object. Both are re-sanitized at // confirm. Defaulted so a client omitting it lands the file in the root. #[serde(default)] pub folder: String, } #[derive(Debug, Deserialize)] pub(crate) struct MediaListQuery { pub folder: Option, } #[derive(Debug, Serialize)] pub(crate) struct MediaFileResponse { pub id: MediaFileId, pub folder: String, pub filename: String, pub content_type: String, pub file_size_bytes: i64, pub media_type: String, pub cdn_url: String, pub markdown_ref: String, pub created_at: String, } #[derive(Debug, Serialize)] pub(crate) struct MediaListResponse { pub files: Vec, pub folders: Vec, } #[derive(Debug, Serialize)] pub(crate) struct MediaFoldersResponse { pub folders: Vec, } // Helpers /// Determine the media file type (image or video) from content type. fn classify_media(content_type: &str) -> Result<(&'static str, FileType)> { if content_type.starts_with("image/") { Ok(("image", FileType::MediaImage)) } else if content_type.starts_with("video/") { Ok(("video", FileType::MediaVideo)) } else { Err(AppError::BadRequest(format!( "Unsupported content type: {content_type}. Only images and videos are allowed." ))) } } fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse { let cdn_url = format!("{}/{}", cdn_base, f.s3_key); // Folder-relative embed path, built from the logical name on the row, not // the physical key, which under scan-then-promote is a content hash // (`{user}/c/{sha}.ext`) that no longer encodes folder/filename. let markdown_ref = if f.folder.is_empty() { format!("![]({})", f.filename) } else { format!("![]({}/{})", f.folder, f.filename) }; MediaFileResponse { id: f.id, folder: f.folder.clone(), filename: f.filename.clone(), content_type: f.content_type.clone(), file_size_bytes: f.file_size_bytes, media_type: f.media_type.clone(), cdn_url, markdown_ref, created_at: f.created_at.to_rfc3339(), } } // Handlers /// Generate a presigned URL for uploading a media file. /// /// POST /api/media/presign #[tracing::instrument(skip_all, name = "media::presign", fields(user_id = %user.id))] pub(super) async fn media_presign( State(db): State, State(storage): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = storage.require_s3()?; let (media_type, file_type) = classify_media(&req.content_type)?; let _ = media_type; // used at confirm time // Validate content type and extension S3Client::validate_content_type(file_type, &req.content_type)?; S3Client::validate_extension(file_type, &req.file_name)?; let folder = sanitize_folder(&req.folder); // Check for path traversal in folder if req.folder.contains("..") { return Err(AppError::BadRequest("Invalid folder name".to_string())); } // Early quota check, images bypass tier, video requires BigFiles+ db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; // Validate the declared size against the static per-type cap AND the user's // tier per-file cap before signing it into Content-Length, so an oversize // media upload is rejected at presign instead of after the bytes are spent // and only caught at confirm. `get_effective_max_file_bytes` returns None for // images (they bypass the tier cap) and the tier limit for video. let max_file_bytes = db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?; super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?; // Filename uniqueness is enforced at confirm time by the // `idx_media_files_user_folder_name` unique index, see `media_confirm`, // which catches the duplicate-INSERT error, rolls back the storage // credit, and returns the clean "already exists" message. It deliberately // does NOT delete the S3 object on that path, see the 23505 branch there // for why. The pre-check we used to do here at presign time was racy (two // concurrent presigns both pass the SELECT, then both try to upload and // one wastes bandwidth) and the confirm-time path is authoritative either // way. // Staging key (unserved); the scan worker promotes it to the content key on a // Clean verdict (C1). The logical library name (folder + filename) is no // longer encoded in the physical key, it is carried on the row and re-derived // from the (sanitized) request at confirm. let _ = &folder; // validated above for early rejection; not woven into the key let s3_key = S3Client::generate_staging_key(&req.file_name); // Track the pending upload so the reaper can clean it up if never confirmed db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; let expires_in = 3600; let upload_url = s3 .presign_upload( &s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), req.file_size_bytes, ) .await .context("presign upload for media file")?; Ok(Json(PresignUploadResponse { upload_url, s3_key: s3_key.into_string(), expires_in, cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), max_file_bytes: None, })) } /// Confirm a completed media file upload. /// /// POST /api/media/confirm #[tracing::instrument(skip_all, name = "media::confirm", fields(user_id = %user.id))] pub(super) async fn media_confirm( State(db): State, State(storage): State, State(scanning): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = storage.require_s3()?; let (media_type, file_type) = classify_media(&req.content_type)?; // Re-validate content type and extension at confirm time (may differ from presign) S3Client::validate_content_type(file_type, &req.content_type)?; S3Client::validate_extension(file_type, &req.file_name)?; // Authorize the staging key: a `staging/{uuid}` key has no user in its path, // so ownership is proved via the `pending_uploads` row recorded at presign, // not a prefix check. Gate before the sniff/size-reject paths so an unowned // (at most another user's in-flight) staging object is never enqueued for // deletion. if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { return Err(AppError::BadRequest("Invalid upload key".to_string())); } // Verify the object exists in S3 if !s3.object_exists(&req.s3_key).await? { return Err(AppError::BadRequest( "Upload not found. Please try uploading again.".to_string(), )); } // Get file size let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| { AppError::BadRequest( "Could not determine file size. Please try uploading again.".to_string(), ) })?; if file_size_bytes as u64 > file_type.max_size() { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected", ) .await; return Err(AppError::BadRequest(format!( "File exceeds maximum size of {} MB", file_type.max_size() / (1024 * 1024) ))); } // Reconcile the real media category against the declared content_type before // tier enforcement. The declared type is client-controlled, it is bound into // the presigned PUT and merely echoed back by S3 metadata, so trusting it // lets a video be declared `image/png` and dodge the BigFiles+ video-tier // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is // detectably a different audio/visual category than declared, reject + orphan. { // Ranged read of just the header, a 4 KB sniff must not transfer the // whole (up to 20 GB) object. Production issues `Range: bytes=0-4095`. let head = s3.download_head(&req.s3_key, 4096).await?; let detected = infer::get(&head).map(|kind| kind.matcher_type()); // `media_type` is only ever "image" or "video" (see `classify_media`). let mismatch = match media_type { // Every allowed image format (jpeg/png/webp/gif) is positively // detected by `infer`, so a declared image MUST sniff as an image. // Requiring a positive image signature, rather than only rejecting // a *detected* video, closes the bypass where a video `infer` // cannot name its container is declared `image/png` to dodge the // BigFiles+ video-tier gate (Run #22 / Run #5 Storage): a video // never sniffs as Image, so it is rejected here either way. "image" => detected != Some(infer::MatcherType::Image), // Declared video: do NOT require a positive video signature, // `infer` cannot classify every valid container (fragmented mp4, // some mov/webm), and there is no tier-evasion incentive to declare // a video as a video. Only reject a still image mislabeled as video. "video" => detected == Some(infer::MatcherType::Image), _ => false, }; if mismatch { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "media_content_type_mismatch", ) .await; let sniffed = match detected { Some(infer::MatcherType::Image) => "image", Some(infer::MatcherType::Video) => "video", _ => "an unrecognized format", }; return Err(AppError::BadRequest(format!( "Uploaded file does not match the declared {media_type} type (detected: {sniffed})." ))); } } // Tier enforcement let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, file_type, file_size_bytes) .await { Ok(max) => max, Err(e) => { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected", ) .await; return Err(e); } }; // Derive the logical library name (folder + filename) from the request, // re-applying the same sanitizers presign used. Under scan-then-promote the // physical key is a content hash that no longer encodes the name (the Run #22 // key-vs-name mismatch it guarded against is gone, the name is now a purely // logical namespace, enforced by the `(user_id, folder, filename)` unique // index, decoupled from the content-addressed object). if req.folder.contains("..") { return Err(AppError::BadRequest("Invalid folder name".to_string())); } let folder = sanitize_folder(&req.folder); let safe_filename = sanitize_filename(&req.file_name); if safe_filename.is_empty() { return Err(AppError::BadRequest("Invalid file name".to_string())); } // Wrap storage credit + pending_uploads clear + media_files INSERT in a // single transaction. The Run #5 audit flagged the previous non-atomic // three-write sequence: a process interruption between writes could leave // a charged storage counter with no row to refund against (storage credit // leak), or a removed pending_uploads row with no media_files row + no // tracker for the reaper (orphan S3 object + over-charge). With the tx, // any rollback restores all three table states; only the S3 object needs // explicit cleanup on failure. // // The unique index on (user_id, folder, filename) raises 23505 inside the // tx; we catch the typed error after rollback and report a clean message. let tx_result: Result = async { let mut tx = db.begin().await?; db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage) .await?; db::pending_uploads::remove_pending_upload(&mut *tx, user.id, &req.s3_key, "main").await?; let row = db::media_files::create( &mut *tx, user.id, &folder, &safe_filename, &req.s3_key, &req.content_type, file_size_bytes, media_type, db::FileScanStatus::Pending.to_string().as_str(), ) .await?; tx.commit().await?; Ok(row) } .await; let inserted = match tx_result { Ok(row) => row, Err(e) => { tracing::warn!(error = ?e, "media_confirm transaction failed"); // Detect the duplicate case via the structured Postgres SQLSTATE // (23505). The previous `e.to_string()` substring check broke when // the AppError wrapper changed how the inner sqlx error rendered. if let AppError::Database(sqlx::Error::Database(db_err)) = &e && db_err.code().as_deref() == Some("23505") { // Two different situations raise 23505 here and this branch // cannot tell them apart, so it fails safe and never deletes // (Run #11 HIGH): // // - A retried or concurrent confirm of THIS upload. The first // confirm committed a row pointing at exactly this // `req.s3_key`, so deleting the object would torpedo the // file that live row serves. // - A genuinely different upload that collides on // (user, folder, filename). Staging keys are per-presign // UUIDs, so this object is an orphan, but the tx rolled // back and left its `pending_uploads` row intact, which is // what the reaper collects it by. // // The tx already rolled back the storage charge either way. // Reject the duplicate without touching S3. return Err(AppError::BadRequest(format!( "A file named '{}' already exists in folder '{}'.", safe_filename, if folder.is_empty() { "(root)" } else { &folder } ))); } // Any other failure: the tx rolled back and no row references this // freshly-uploaded object, so it's a genuine orphan, clean it up. super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected", ) .await; return Err(e); } }; // Scan enqueue + scan_status flip AFTER the INSERT commits via the shared // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner // dev/test environment doesn't leave the row Pending forever. let scan_status = commit_upload( &db, scanning.scanner.as_ref(), CommitTarget::Media(inserted.id), &req.s3_key, file_type, user.id, file_size_bytes, ) .await?; tracing::info!( "Media upload confirmed: user={}, folder={}, file={}, size={}", user.id, folder, safe_filename, file_size_bytes ); let pending_review = if scan_status == db::FileScanStatus::HeldForReview { Some(true) } else { None }; Ok(Json(ConfirmUploadResponse { success: true, pending_review, })) } /// List media files for the authenticated user. /// /// GET /api/media?folder={folder} #[tracing::instrument(skip_all, name = "media::list", fields(user_id = %user.id))] pub(super) async fn media_list( State(db): State, State(config): State, AuthUser(user): AuthUser, Query(query): Query, ) -> Result { let cdn_base = config.cdn_base_url.as_str(); let files = db::media_files::list_by_user_folder(&db, user.id, query.folder.as_deref()).await?; let folders = db::media_files::list_folders(&db, user.id).await?; let file_responses: Vec = files .iter() .map(|f| file_to_response(f, cdn_base)) .collect(); Ok(Json(MediaListResponse { files: file_responses, folders, })) } /// List distinct folder names for the authenticated user. /// /// GET /api/media/folders #[tracing::instrument(skip_all, name = "media::folders", fields(user_id = %user.id))] pub(super) async fn media_folders( State(db): State, AuthUser(user): AuthUser, ) -> Result { let folders = db::media_files::list_folders(&db, user.id).await?; Ok(Json(MediaFoldersResponse { folders })) } /// Delete a media file. /// /// DELETE /api/media/{id} #[tracing::instrument(skip_all, name = "media::delete", fields(user_id = %user.id, media_id = %id))] pub(super) async fn media_delete( State(db): State, State(storage): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; // Require S3 to be configured, but the actual delete goes through the // durable queue (the sanctioned deletion path) rather than a direct call. storage.require_s3()?; let file = db::media_files::get_by_id(&db, id) .await? .ok_or(AppError::NotFound)?; // Verify ownership if file.user_id != user.id { return Err(AppError::Forbidden); } // Commit the DB delete + storage refund together. Doing the DB delete // FIRST (and only refunding when it commits) avoids the previous race // where a failed inline S3 delete still decremented the counter. // // Refund ONLY when the DELETE actually removed a row: `get_by_id` above is // outside the tx, so a concurrent double-delete (double-click / retry) can // let both requests past it; gating the decrement on `delete(...).is_some()` // stops the second one from decrementing storage a second time and // under-counting `storage_used_bytes` in the creator's favor (Run #12 LOW, // the delete-side mirror of the confirm handlers' rows-affected discipline). // Row delete + storage refund + S3-deletion enqueue all in ONE transaction. // Enqueueing inside the tx (rather than after commit) closes the crash window // where a commit followed by a failed post-commit enqueue orphaned the object // with no durable record (Run #18 Storage B6, the same in-tx ordering // delete_version adopted). The refund + enqueue use the DELETE's own returned // row (`deleted`), not the pre-tx `get_by_id` read. let mut tx = db.begin().await?; let deleted = db::media_files::delete(&mut *tx, id, user.id).await?; if let Some(ref row) = deleted { db::creator_tiers::decrement_storage_used(&mut *tx, user.id, row.file_size_bytes).await?; db::pending_s3_deletions::enqueue_deletions( &mut *tx, &[(row.s3_key.clone(), "main".to_string())], "media_delete", ) .await?; } tx.commit().await?; // The durable queue entry committed above is the source of truth; the // queue worker performs the actual S3 delete (the only sanctioned path). tracing::info!("Media file deleted: id={}, user={}", id, user.id); Ok(Json(ConfirmUploadResponse { success: true, pending_review: None, })) } #[cfg(test)] mod tests { use super::*; use chrono::Utc; fn make_media_file(folder: &str, filename: &str, s3_key: &str) -> db::DbMediaFile { db::DbMediaFile { id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".parse().unwrap(), user_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(), folder: folder.to_string(), filename: filename.to_string(), s3_key: s3_key.to_string(), content_type: "image/png".to_string(), file_size_bytes: 1024, media_type: "image".to_string(), scan_status: "clean".to_string(), created_at: Utc::now(), } } #[test] fn classify_media_image() { let (media_type, file_type) = classify_media("image/png").unwrap(); assert_eq!(media_type, "image"); assert_eq!(file_type, FileType::MediaImage); } #[test] fn classify_media_video() { let (media_type, file_type) = classify_media("video/mp4").unwrap(); assert_eq!(media_type, "video"); assert_eq!(file_type, FileType::MediaVideo); } #[test] fn classify_media_rejects_audio() { assert!(classify_media("audio/mpeg").is_err()); } #[test] fn classify_media_rejects_text() { assert!(classify_media("text/plain").is_err()); } #[test] fn classify_media_rejects_empty() { assert!(classify_media("").is_err()); } #[test] fn file_to_response_root_folder() { let f = make_media_file( "", "photo.png", "11111111-1111-1111-1111-111111111111/media/photo.png", ); let resp = file_to_response(&f, "https://cdn.example.com"); assert_eq!( resp.cdn_url, "https://cdn.example.com/11111111-1111-1111-1111-111111111111/media/photo.png" ); assert_eq!(resp.markdown_ref, "![](photo.png)"); } #[test] fn file_to_response_with_folder() { let f = make_media_file( "screenshots", "shot.png", "11111111-1111-1111-1111-111111111111/media/screenshots/shot.png", ); let resp = file_to_response(&f, "https://cdn.example.com"); assert_eq!(resp.markdown_ref, "![](screenshots/shot.png)"); } #[test] fn file_to_response_preserves_metadata() { let f = make_media_file( "docs", "img.png", "11111111-1111-1111-1111-111111111111/media/docs/img.png", ); let resp = file_to_response(&f, "https://cdn.test"); assert_eq!(resp.folder, "docs"); assert_eq!(resp.filename, "img.png"); assert_eq!(resp.file_size_bytes, 1024); assert_eq!(resp.media_type, "image"); } }