//! 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::{ extract::{Path, Query, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, MediaFileId}, error::{AppError, Result, ResultExt}, storage::{sanitize_folder, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, AppState, }; use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse}; // ============================================================================= // Request / Response Types // ============================================================================= #[derive(Debug, Deserialize)] pub struct MediaPresignRequest { pub file_name: String, pub content_type: String, #[serde(default)] pub folder: String, } #[derive(Debug, Deserialize)] pub struct MediaConfirmRequest { pub s3_key: String, pub file_name: String, pub content_type: String, #[serde(default)] pub folder: String, } #[derive(Debug, Deserialize)] pub struct MediaListQuery { pub folder: Option, } #[derive(Debug, Serialize)] pub 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 struct MediaListResponse { pub files: Vec, pub folders: Vec, } #[derive(Debug, Serialize)] pub 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: {}. Only images and videos are allowed.", content_type ))) } } fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse { let cdn_url = format!("{}/{}", cdn_base, f.s3_key); let markdown_ref = if f.folder.is_empty() { format!("![]({})", f.filename) } else { format!("![]({})", f.s3_key.trim_start_matches(&format!("{}/media/", f.user_id))) }; 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.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)?; // Sanitize folder 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(&state.db, user.id, file_type).await?; // 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, deletes the orphaned S3 object, and returns the clean // "already exists" message. 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. // Generate S3 key let s3_key = S3Client::generate_media_key(user.id, &folder, &req.file_name); // Track the pending upload so the reaper can clean it up if never confirmed db::pending_uploads::record_pending_upload(&state.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), None) .await .context("presign upload for media file")?; Ok(Json(PresignUploadResponse { upload_url, s3_key, 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.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)?; // Validate S3 key belongs to this user (prevent cross-user file reference) let expected_prefix = format!("{}/media/", user.id); if !req.s3_key.starts_with(&expected_prefix) { 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() { s3.delete_object(&req.s3_key).await.ok(); return Err(AppError::BadRequest(format!( "File exceeds maximum size of {} MB", file_type.max_size() / (1024 * 1024) ))); } // Tier enforcement let max_storage = match db::creator_tiers::check_upload_allowed( &state.db, user.id, file_type, file_size_bytes, ) .await { Ok(max) => max, Err(e) => { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } }; let folder = sanitize_folder(&req.folder); let safe_filename = req.file_name .chars() .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_') .collect::(); // 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 = state.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).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") { // A 23505 here means this key is ALREADY live: media keys are // deterministic by (user, folder, filename), so a duplicate // filename and a concurrent/retried confirm both resolve to the // same key, which is referenced by the committed row. The tx // already rolled back the storage charge, so we must NOT delete // the object — doing so would torpedo the existing file the live // row points at (Run #11 HIGH). 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. s3.delete_object(&req.s3_key).await.ok(); 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( &state, 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(state): State, AuthUser(user): AuthUser, Query(query): Query, ) -> Result { // CDN base falls back to the production host so dev/test environments // without CDN config still render plausible URLs. In production a // missing `cdn_base_url` is an operator-side misconfiguration; we log // a WARN once per process so it surfaces without blocking the request. let cdn_base = if let Some(base) = state.config.cdn_base_url.as_deref() { base } else { static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); WARNED.get_or_init(|| { tracing::warn!( "cdn_base_url not configured; falling back to https://cdn.makenot.work for media URLs" ); }); "https://cdn.makenot.work" }; let files = db::media_files::list_by_user_folder( &state.db, user.id, query.folder.as_deref(), ) .await?; let folders = db::media_files::list_folders(&state.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(state): State, AuthUser(user): AuthUser, ) -> Result { let folders = db::media_files::list_folders(&state.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(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; let file = db::media_files::get_by_id(&state.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). let mut tx = state.db.begin().await?; let deleted = db::media_files::delete(&mut *tx, id).await?; if deleted.is_some() { db::creator_tiers::decrement_storage_used(&mut *tx, user.id, file.file_size_bytes).await?; } tx.commit().await?; // Enqueue S3 deletion only AFTER the DB commit succeeds. The Run #6 audit // caught the previous ordering: if `tx.commit()` failed, the queue would // delete the S3 object out from under the still-live DB row. if let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &[(file.s3_key.clone(), "main".to_string())], "media_delete", ).await { tracing::warn!(error = ?e, "failed to enqueue S3 deletion for media file"); } // Best-effort inline S3 delete. If this fails, the enqueued pending // deletion above is the source of truth — the worker will retry. if let Err(e) = s3.delete_object(&file.s3_key).await { tracing::warn!(s3_key = %file.s3_key, error = ?e, "S3 delete failed for media file; pending_s3_deletions worker will retry"); } 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"); } }