//! Presigned upload and confirm handlers for project and item images. use axum::{ extract::State, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, ItemId, ProjectId}, error::{AppError, Result, ResultExt}, storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, AppState, }; use super::{commit_upload, CommitTarget, PresignUploadResponse}; /// JSON input for requesting a presigned project image upload URL. #[derive(Debug, Deserialize)] pub struct ProjectImagePresignRequest { pub project_id: ProjectId, pub file_name: String, pub content_type: String, } /// JSON input for confirming a completed project image upload. #[derive(Debug, Deserialize)] pub struct ProjectImageConfirmRequest { pub project_id: ProjectId, pub s3_key: String, } /// JSON response from a successful project image confirm. #[derive(Debug, Serialize)] pub struct ProjectImageConfirmResponse { pub success: bool, pub image_url: String, } /// JSON input for requesting a presigned item image upload URL. #[derive(Debug, Deserialize)] pub struct ItemImagePresignRequest { pub item_id: ItemId, pub file_name: String, pub content_type: String, } /// JSON input for confirming a completed item image upload. #[derive(Debug, Deserialize)] pub struct ItemImageConfirmRequest { pub item_id: ItemId, pub s3_key: String, } /// Generate a presigned URL for uploading a project image /// /// POST /api/projects/image/presign /// /// Requires authentication. User must own the project. #[tracing::instrument(skip_all, name = "storage::project_image_presign", fields(user_id = %user.id))] pub(super) async fn project_image_presign( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; let file_type = FileType::Cover; S3Client::validate_content_type(file_type, &req.content_type)?; S3Client::validate_extension(file_type, &req.file_name)?; // Verify user owns the project let project = db::projects::get_project_by_id(&state.db, req.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != user.id { return Err(AppError::Forbidden); } // Early quota check db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; let s3_key = S3Client::generate_project_image_key(req.project_id, &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 project image")?; Ok(Json(PresignUploadResponse { upload_url, s3_key, expires_in, cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), max_file_bytes: None, })) } /// Confirm a project image upload, scan, store URL /// /// POST /api/projects/image/confirm /// /// Requires authentication. User must own the project. #[tracing::instrument(skip_all, name = "storage::project_image_confirm", fields(user_id = %user.id))] pub(super) async fn project_image_confirm( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; // Verify user owns the project let project = db::projects::get_project_by_id(&state.db, req.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != user.id { return Err(AppError::Forbidden); } // Validate S3 key belongs to this project (prevent cross-project file reference) let expected_prefix = format!("projects/{}/image/", req.project_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(), )); } // Enforce file size limit 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 > FileType::Cover.max_size() { s3.delete_object(&req.s3_key).await.ok(); return Err(AppError::BadRequest(format!( "File exceeds maximum size of {} MB", FileType::Cover.max_size() / (1024 * 1024) ))); } // Idempotency: if the project already references this same s3_key, return success. // The Run #6 audit caught a silent-data-loss bug here: without this check, a benign // retry would queue `req.s3_key` (== current `cover_image_url`) for deletion. if let Some(ref cur_url) = project.cover_image_url && let Some(cur_key) = storage::extract_s3_key_from_url( cur_url, state.config.cdn_base_url.as_deref(), Some(s3.bucket()), state.config.storage.as_ref().map(|c| c.endpoint.as_str()), ) && cur_key == req.s3_key { // Still clear pending_uploads — orphan reaper would otherwise delete // the live S3 object 24h later (Run #7 HIGH-1). if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await { tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); } return Ok(Json(ProjectImageConfirmResponse { success: true, image_url: cur_url.clone(), })); } // Enforce tier-based limits let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await { Ok(max) => max, Err(e) => { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } }; // Probe the old S3 object's size FIRST (async, before any tx). If the row // references an old image we MUST determine its size or the storage counter // drifts on every replacement; treating Err/Ok(None) as "no old image" would // silently over-count. This probe stays outside the transaction so we never // hold a DB connection across an S3 round-trip. let mut replace_old_size: Option = None; let old_key_to_delete: Option = if let Some(ref old_url) = project.cover_image_url && let Some(old_key) = storage::extract_s3_key_from_url( old_url, state.config.cdn_base_url.as_deref(), Some(s3.bucket()), state.config.storage.as_ref().map(|c| c.endpoint.as_str()), ) { match s3.object_size(&old_key).await { Ok(Some(old_size)) if old_size > 0 => { replace_old_size = Some(old_size); Some(old_key) } Ok(Some(_)) | Ok(None) => { // Old URL parsed but the object is gone (or zero-sized): treat as a // fresh upload — nothing to refund. Still queue the old key for // deletion in case of S3 eventual-consistency. Some(old_key) } Err(e) => { // S3 probe failed (transient). Refuse to write — letting a probe failure // silently over-count storage on every replace is the bug this branch exists to prevent. s3.delete_object(&req.s3_key).await.ok(); tracing::warn!(key = %old_key, error = ?e, "S3 probe failed during project image replace"); return Err(AppError::ServiceUnavailable( "Could not verify previous image. Please try again.".to_string(), )); } } } else { None }; // Build permanent URL (async, before the tx). let image_url = storage::build_project_image_url( s3.as_ref(), state.config.cdn_base_url.as_deref(), &req.s3_key, ).await?; // Storage credit + project image URL UPDATE in ONE transaction. A rollback // restores the counter, so the previous compensating `rollback_and_orphan` // math (with a second S3 probe and swallowed `.ok()`s) is gone. `commit_upload` // stays AFTER the commit. `Ok(false)` = ownership filter no-matched (project // deleted/transferred mid-flight); the tx rolled back, nothing charged. let committed: Result = async { let mut tx = state.db.begin().await?; db::creator_tiers::try_apply_storage_on( &mut tx, user.id, replace_old_size, file_size_bytes, max_storage, ).await?; let ok = db::projects::update_project_image_url(&mut *tx, req.project_id, user.id, &image_url).await?; if !ok { return Ok(false); } tx.commit().await?; Ok(true) } .await; match committed { Err(e) => { // tx rolled back — counter unchanged. Orphan-queue the new key for cleanup. super::enqueue_s3_orphan(&state.db, &req.s3_key, "project_image_update_failed").await; return Err(e); } Ok(false) => { super::enqueue_s3_orphan(&state.db, &req.s3_key, "project_image_update_failed").await; return Err(AppError::BadRequest( "Project was modified concurrently. Please try uploading again.".to_string(), )); } Ok(true) => {} } // Clear the pending upload record now that the upload is committed db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?; // Enqueue old S3 object for durable deletion now that the new URL is committed. if let Some(old_key) = old_key_to_delete && let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &[(old_key.clone(), "main".to_string())], "project_image_replace", ).await { tracing::warn!(key = %old_key, error = ?e, "failed to enqueue old project image for deletion"); } // Scan enqueue AFTER the DB write commits (Phase 5 chronic fix — the same // ordering rule that uploads/versions/media follow via `commit_upload`). commit_upload( &state, CommitTarget::ProjectImage(req.project_id), &req.s3_key, FileType::Cover, user.id, file_size_bytes, ).await?; // Bump cache db::projects::bump_cache_generation(&state.db, req.project_id).await?; tracing::info!( "Project image confirmed: project={}, key={}, size={}", req.project_id, req.s3_key, file_size_bytes ); Ok(Json(ProjectImageConfirmResponse { success: true, image_url, })) } /// Generate a presigned URL for uploading an item image (logo/cover) /// /// POST /api/items/image/presign /// /// Requires authentication. User must own the item. #[tracing::instrument(skip_all, name = "storage::item_image_presign", fields(user_id = %user.id))] pub(super) async fn item_image_presign( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; let file_type = FileType::Cover; S3Client::validate_content_type(file_type, &req.content_type)?; S3Client::validate_extension(file_type, &req.file_name)?; // Verify user owns the item let owner = db::items::get_item_owner(&state.db, req.item_id) .await? .ok_or(AppError::NotFound)?; if owner != user.id { return Err(AppError::Forbidden); } // Early quota check db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &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 item image")?; Ok(Json(PresignUploadResponse { upload_url, s3_key, expires_in, cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), max_file_bytes: None, })) } /// Confirm an item image upload, scan, store URL /// /// POST /api/items/image/confirm /// /// Requires authentication. User must own the item. #[tracing::instrument(skip_all, name = "storage::item_image_confirm", fields(user_id = %user.id))] pub(super) async fn item_image_confirm( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; // Verify user owns the item let owner = db::items::get_item_owner(&state.db, req.item_id) .await? .ok_or(AppError::NotFound)?; if owner != user.id { return Err(AppError::Forbidden); } // Validate S3 key belongs to this user + item (prevent cross-user file reference) let expected_prefix = format!("{}/{}/", user.id, req.item_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(), )); } // Enforce file size limit 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 > FileType::Cover.max_size() { s3.delete_object(&req.s3_key).await.ok(); return Err(AppError::BadRequest(format!( "File exceeds maximum size of {} MB", FileType::Cover.max_size() / (1024 * 1024) ))); } // Enforce tier-based limits let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await { Ok(max) => max, Err(e) => { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } }; // Idempotency: if cover_s3_key already matches, return success (no-op). // Otherwise capture the existing cover for atomic replacement below. let existing_item = db::items::get_item_by_id(&state.db, req.item_id).await?; if let Some(ref item) = existing_item && item.cover_s3_key.as_deref() == Some(&req.s3_key) { // Still clear pending_uploads — orphan reaper would otherwise delete // the live S3 object 24h later (Run #7 HIGH-1). if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await { tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); } return Ok(Json(super::images::ProjectImageConfirmResponse { success: true, image_url: item.cover_image_url.clone().unwrap_or_default(), })); } // Old cover key+size come straight from the already-loaded item row (no S3 // probe needed). We replace only when the old object has a real size; split // that decision once into the replace-size and the key to clean up. let (replace_old_size, old_key_to_delete): (Option, Option) = match existing_item.as_ref().and_then(|i| Some((i.cover_s3_key.clone()?, i.cover_file_size_bytes.unwrap_or(0)))) { Some((key, size)) if size > 0 => (Some(size), Some(key)), _ => (None, None), }; let image_url = storage::build_project_image_url( s3.as_ref(), state.config.cdn_base_url.as_deref(), &req.s3_key, ).await?; // Storage credit + item cover UPDATE in ONE transaction (Run #7 HIGH-2 made // these atomic via compensating actions; this makes them atomic via a real // tx — a rollback restores the counter with no swallowed-`.ok()` math). // `commit_upload` stays AFTER the commit. `Ok(false)` = ownership filter // no-matched (item deleted/moved mid-flight); the tx rolled back, nothing charged. let committed: Result = async { let mut tx = state.db.begin().await?; db::creator_tiers::try_apply_storage_on( &mut tx, user.id, replace_old_size, file_size_bytes, max_storage, ).await?; let ok = db::items::update_item_cover( &mut *tx, req.item_id, user.id, &image_url, &req.s3_key, file_size_bytes, ).await?; if !ok { return Ok(false); } tx.commit().await?; Ok(true) } .await; match committed { Err(e) => { super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_image_update_failed").await; return Err(e); } Ok(false) => { super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_image_update_failed").await; return Err(AppError::BadRequest( "Item was modified concurrently. Please try uploading again.".to_string(), )); } Ok(true) => {} } db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?; if let Some(old_key) = old_key_to_delete && let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &[(old_key.clone(), "main".to_string())], "item_image_replace", ).await { tracing::warn!(key = %old_key, error = ?e, "failed to enqueue old item cover for deletion"); } // Scan enqueue + scan_status flip AFTER the DB write — same ordering rule // as uploads/versions/media. The Run #6 audit caught this same bug here. commit_upload( &state, CommitTarget::ItemImage(req.item_id), &req.s3_key, FileType::Cover, user.id, file_size_bytes, ).await?; // Bump project cache if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await { tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after image upload"); } tracing::info!( "Item image confirmed: item={}, key={}, size={}", req.item_id, req.s3_key, file_size_bytes ); Ok(Json(ProjectImageConfirmResponse { success: true, image_url, })) }