//! 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")] 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")] 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); } }; // Atomically replace storage (decrement old, increment new) BEFORE writing the DB record. // Probe the old S3 object's size; if the row references an old image we MUST be able to // determine its size or the storage counter drifts on every replacement. Falling into the // "no old image" branch on Err / Ok(None) would silently over-count. 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 => { if let Err(e) = db::creator_tiers::try_replace_storage(&state.db, user.id, old_size, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } Some(old_key) } Ok(Some(_)) | Ok(None) => { // Old URL parsed but the object is gone (or zero-sized): treat as a // fresh upload — there's nothing to refund, so just increment. if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } Some(old_key) // still queue deletion (it returned None, but try anyway in case of S3 eventual-consistency) } 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 { // No old image — just increment if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } None }; // Clear the pending upload record now that the upload is confirmed db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?; // Build permanent URL let image_url = storage::build_project_image_url( s3.as_ref(), state.config.cdn_base_url.as_deref(), &req.s3_key, ).await?; // Store URL in database. By this point we've committed storage credit + cleared // pending_uploads, so a failure here must roll both back AND queue the new key // for orphan deletion — otherwise the S3 object leaks with no reaper coverage // and the storage counter stays inflated. Treat Ok(false) the same as Err: the // ownership filter no-matched (project was deleted or transferred mid-flight) // and no row references the new key. let rollback_and_orphan = || async { match old_key_to_delete.as_deref() { Some(_) => { // We did a replace earlier. Determine old size from a fresh probe; // if probe fails, best-effort decrement of just the new size. if let Ok(Some(old_size)) = s3.object_size(old_key_to_delete.as_deref().unwrap_or("")).await && old_size > 0 { db::creator_tiers::try_replace_storage( &state.db, user.id, file_size_bytes, old_size, i64::MAX, ).await.ok(); } else { db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size_bytes).await.ok(); } } None => { db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size_bytes).await.ok(); } } super::enqueue_s3_orphan(&state.db, &req.s3_key, "project_image_update_failed").await; }; match db::projects::update_project_image_url(&state.db, req.project_id, user.id, &image_url).await { Err(e) => { rollback_and_orphan().await; return Err(e.into()); } Ok(false) => { rollback_and_orphan().await; return Err(AppError::BadRequest( "Project was modified concurrently. Please try uploading again.".to_string(), )); } Ok(true) => {} } // 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")] 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")] 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(), })); } // Atomically replace storage (decrement old, increment new) BEFORE writing the DB record. // Old S3 deletion is enqueued only after the swap + UPDATE commit. let old_cover = existing_item.as_ref().and_then(|i| { let key = i.cover_s3_key.clone()?; let size = i.cover_file_size_bytes.unwrap_or(0); Some((key, size)) }); let old_key_to_delete: Option = if let Some((ref old_key, old_size)) = old_cover && old_size > 0 { if let Err(e) = db::creator_tiers::try_replace_storage(&state.db, user.id, old_size, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } Some(old_key.clone()) } else { if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } None }; db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?; let image_url = storage::build_project_image_url( s3.as_ref(), state.config.cdn_base_url.as_deref(), &req.s3_key, ).await?; // Same rollback discipline as project_image_confirm — Run #7 HIGH-2. // Ok(false) means the ownership filter no-matched (item deleted or moved // between projects mid-flight); treat identically to Err. let item_cover_rollback = || async { if let Some((_, old_size)) = old_cover.as_ref() && *old_size > 0 { db::creator_tiers::try_replace_storage( &state.db, user.id, file_size_bytes, *old_size, i64::MAX, ).await.ok(); } else { db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size_bytes).await.ok(); } super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_image_update_failed").await; }; match db::items::update_item_cover(&state.db, req.item_id, user.id, &image_url, &req.s3_key, file_size_bytes).await { Err(e) => { item_cover_rollback().await; return Err(e); } Ok(false) => { item_cover_rollback().await; return Err(AppError::BadRequest( "Item was modified concurrently. Please try uploading again.".to_string(), )); } Ok(true) => {} } 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, })) }