//! Presigned upload / confirm / delete / reorder handlers for item & project //! image galleries (launchplan S.1). //! //! Reuses the cover-image S3 path wholesale (validation, presign, the atomic //! storage-credit transaction, scan enqueue ordering, S3-deletion queue). The //! one difference from cover upload: a gallery image is ADD-only, confirm //! inserts a new row and is a pure storage increment (no old-key probe), and a //! separate delete decrements storage. cover_image_url is never touched. use axum::{ Json, extract::{Path, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use crate::{ AppStorage, Scanning, auth::AuthUser, config::Config, db::{self, ImageId, ItemId, ProjectId, UserId}, error::{AppError, Result, ResultExt}, storage::{self, CACHE_CONTROL_IMMUTABLE, FileType, S3Client}, }; use super::{CommitTarget, PresignUploadResponse, commit_upload}; /// Which kind of entity a gallery image hangs off. #[derive(Debug, Clone, Copy)] enum GalleryTarget { Item, Project, } impl GalleryTarget { fn parse(s: &str) -> Result { match s { "item" => Ok(GalleryTarget::Item), "project" => Ok(GalleryTarget::Project), _ => Err(AppError::BadRequest( "Invalid gallery target type".to_string(), )), } } } /// Verify `user` owns the target entity. Returns the parsed kind. NotFound if /// the entity does not exist, Forbidden if owned by someone else. async fn require_owned( db: &PgPool, target: GalleryTarget, target_id: Uuid, user_id: UserId, ) -> Result<()> { let owner = match target { GalleryTarget::Item => db::items::get_item_owner(db, ItemId::from(target_id)).await?, GalleryTarget::Project => db::projects::get_project_by_id(db, ProjectId::from(target_id)) .await? .map(|p| p.user_id), }; match owner { Some(o) if o == user_id => Ok(()), Some(_) => Err(AppError::Forbidden), None => Err(AppError::NotFound), } } // List (owner-only, for the wizard manager) #[derive(Debug, Serialize)] struct GalleryListItem { id: ImageId, image_url: String, alt: String, } /// GET /api/gallery/list/{target_type}/{target_id}, current gallery, owner-only. /// Drives the wizard manager (the public page renders the same rows server-side). #[tracing::instrument(skip_all, name = "storage::gallery_list", fields(user_id = %user.id, %target_type, %target_id))] pub(super) async fn gallery_list( State(db): State, AuthUser(user): AuthUser, Path((target_type, target_id)): Path<(String, Uuid)>, ) -> Result { let target = GalleryTarget::parse(&target_type)?; require_owned(&db, target, target_id, user.id).await?; let rows = match target { GalleryTarget::Item => { db::gallery_images::list_for_item(&db, ItemId::from(target_id)).await? } GalleryTarget::Project => { db::gallery_images::list_for_project(&db, ProjectId::from(target_id)).await? } }; let items: Vec = rows .into_iter() .map(|g| GalleryListItem { id: g.id, image_url: g.image_url, alt: g.alt, }) .collect(); Ok(Json(items)) } // Presign #[derive(Debug, Deserialize)] pub(super) struct GalleryPresignRequest { pub target_type: String, pub target_id: Uuid, pub file_name: String, pub content_type: 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. #[serde(default)] pub file_size_bytes: Option, } /// POST /api/gallery/presign, presign a gallery image upload. #[tracing::instrument(skip_all, name = "storage::gallery_presign", fields(user_id = %user.id))] pub(super) async fn gallery_presign( State(db): State, State(storage): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = storage.require_s3()?; let target = GalleryTarget::parse(&req.target_type)?; let file_type = FileType::Cover; S3Client::validate_content_type(file_type, &req.content_type)?; S3Client::validate_extension(file_type, &req.file_name)?; require_owned(&db, target, req.target_id, user.id).await?; // Early per-entity cap check (authoritative re-check happens at confirm). let count = match target { GalleryTarget::Item => { db::gallery_images::count_for_item(&db, ItemId::from(req.target_id)).await? } GalleryTarget::Project => { db::gallery_images::count_for_project(&db, ProjectId::from(req.target_id)).await? } }; if count >= db::gallery_images::MAX_GALLERY_IMAGES { return Err(AppError::BadRequest(format!( "Gallery is full (max {} images)", db::gallery_images::MAX_GALLERY_IMAGES ))); } db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; // Validate the declared size (if any) before signing it into Content-Length. super::validate_declared_upload_size(req.file_size_bytes, file_type, None)?; // Staging key (unserved); the scan worker promotes it to the content key and // rebuilds the row's public `image_url` on a Clean verdict (C1). Its random // uuid also keeps multiple gallery uploads from colliding. let s3_key = S3Client::generate_staging_key(&req.file_name); 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 gallery image")?; 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 #[derive(Debug, Deserialize)] pub(super) struct GalleryConfirmRequest { pub target_type: String, pub target_id: Uuid, pub s3_key: String, #[serde(default)] pub alt: String, } #[derive(Debug, Serialize)] pub(super) struct GalleryConfirmResponse { pub success: bool, pub id: ImageId, pub image_url: String, pub alt: String, } /// Outcome of the confirm transaction: a fresh insert, or the pre-existing row /// found on a replayed confirm (STOR-S1 idempotency, no second insert, no /// second storage charge). enum GalleryOutcome { Inserted(ImageId), Existing(db::gallery_images::GalleryImage), } /// POST /api/gallery/confirm, finalize a gallery image upload. #[tracing::instrument(skip_all, name = "storage::gallery_confirm", fields(user_id = %user.id))] pub(super) async fn gallery_confirm( State(db): State, State(storage): State, State(config): State, State(scanning): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = storage.require_s3()?; let target = GalleryTarget::parse(&req.target_type)?; require_owned(&db, target, req.target_id, user.id).await?; // Ownership of the staging key is proved inside the confirm transaction below // (after the replay short-circuit) via `pending_uploads`, a `staging/{uuid}` // key carries no entity in its path for a prefix check to bind against. if !s3.object_exists(&req.s3_key).await? { return Err(AppError::BadRequest( "Upload not found. Please try uploading again.".to_string(), )); } 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() { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected", ) .await; return Err(AppError::BadRequest(format!( "File exceeds maximum size of {} MB", FileType::Cover.max_size() / (1024 * 1024) ))); } // Authoritative cap re-check (presign's was best-effort/UX). let count = match target { GalleryTarget::Item => { db::gallery_images::count_for_item(&db, ItemId::from(req.target_id)).await? } GalleryTarget::Project => { db::gallery_images::count_for_project(&db, ProjectId::from(req.target_id)).await? } }; if count >= db::gallery_images::MAX_GALLERY_IMAGES { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected", ) .await; return Err(AppError::BadRequest(format!( "Gallery is full (max {} images)", db::gallery_images::MAX_GALLERY_IMAGES ))); } let max_storage = match db::creator_tiers::check_upload_allowed( &db, user.id, FileType::Cover, file_size_bytes, ) .await { Ok(max) => max, Err(e) => { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected", ) .await; return Err(e); } }; let image_url = storage::build_project_image_url(&config.cdn_base_url, &req.s3_key); let alt = req.alt.trim().to_string(); // Advisory-lock key for serializing concurrent confirms on this gallery: // class separates item vs project; obj folds the target UUID to i32 (a // collision only over-serializes two unrelated galleries, never under-). let gallery_lock_class: i32 = match target { GalleryTarget::Item => 0, GalleryTarget::Project => 1, }; let gallery_lock_obj: i32 = { let b = req.target_id.as_bytes(); i32::from_le_bytes([b[0], b[1], b[2], b[3]]) ^ i32::from_le_bytes([b[4], b[5], b[6], b[7]]) ^ i32::from_le_bytes([b[8], b[9], b[10], b[11]]) ^ i32::from_le_bytes([b[12], b[13], b[14], b[15]]) }; // Storage increment + row INSERT in ONE transaction (gallery is add-only, so // a pure increment, no old-key replacement). A rollback restores the counter. // Returns the existing row on a replayed confirm (idempotent, no re-charge) // or the freshly-inserted id otherwise. let committed: Result = async { let mut tx = db.begin().await?; // Serialize concurrent confirms for this gallery and re-count INSIDE the // tx, so two inserts that both passed the best-effort pre-check above // can't push it over MAX_GALLERY_IMAGES (Run #14 Storage LOW). A failure // here routes through the orphan-enqueue cleanup below. sqlx::query("SELECT pg_advisory_xact_lock($1, $2)") .bind(gallery_lock_class) .bind(gallery_lock_obj) .execute(&mut *tx) .await?; // Idempotency guard (STOR-S1): a replayed confirm for an s3_key already // recorded must not insert a second row or charge storage again. The // advisory lock above serializes confirms for this gallery, so this // check-then-insert is race-safe; the UNIQUE index on s3_key (migration // 147) is the durable backstop against any cross-process gap. let existing = match target { GalleryTarget::Item => { db::gallery_images::find_for_item_by_key( &mut *tx, ItemId::from(req.target_id), &req.s3_key, ) .await? } GalleryTarget::Project => { db::gallery_images::find_for_project_by_key( &mut *tx, ProjectId::from(req.target_id), &req.s3_key, ) .await? } }; if let Some(img) = existing { return Ok(GalleryOutcome::Existing(img)); } // Authorize a FRESH insert: the caller must have presigned this staging // key (recorded against them in `pending_uploads`). Checked here, after // the replay short-circuit, a replayed confirm's pending row was already // consumed, so only new rows are gated. A `staging/{uuid}` key has no // entity in its path, so this lookup (not a prefix check) is what stops a // confirm from stealing another user's staging object. if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { return Err(AppError::BadRequest("Invalid upload key".to_string())); } let count_in_tx = match target { GalleryTarget::Item => { db::gallery_images::count_for_item(&mut *tx, ItemId::from(req.target_id)).await? } GalleryTarget::Project => { db::gallery_images::count_for_project(&mut *tx, ProjectId::from(req.target_id)) .await? } }; if count_in_tx >= db::gallery_images::MAX_GALLERY_IMAGES { return Err(AppError::BadRequest(format!( "Gallery is full (max {} images)", db::gallery_images::MAX_GALLERY_IMAGES ))); } db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage) .await?; let id = match target { GalleryTarget::Item => { db::gallery_images::insert_for_item( &mut *tx, ItemId::from(req.target_id), &req.s3_key, &image_url, &alt, file_size_bytes, ) .await? } GalleryTarget::Project => { db::gallery_images::insert_for_project( &mut *tx, ProjectId::from(req.target_id), &req.s3_key, &image_url, &alt, file_size_bytes, ) .await? } }; tx.commit().await?; Ok(GalleryOutcome::Inserted(id)) } .await; let id = match committed { // Replayed confirm: the object is already a live, recorded gallery row, // nothing was charged or inserted, so do NOT orphan-enqueue (that key is // in use). Return the existing row so the client sees the same success. Ok(GalleryOutcome::Existing(img)) => { return Ok(Json(GalleryConfirmResponse { success: true, id: img.id, image_url: img.image_url, alt: img.alt, })); } Ok(GalleryOutcome::Inserted(id)) => id, Err(e) => { super::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_image_insert_failed", ) .await; return Err(e); } }; db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; // Scan enqueue AFTER the DB write commits (the chronic-ordering rule). commit_upload( &db, scanning.scanner.as_ref(), CommitTarget::GalleryImage(id.into()), &req.s3_key, FileType::Cover, user.id, file_size_bytes, ) .await?; bump_target_cache(&db, target, req.target_id).await; Ok(Json(GalleryConfirmResponse { success: true, id, image_url, alt, })) } // Delete /// DELETE /api/gallery/image/{target_type}/{image_id}, remove one gallery image. #[tracing::instrument(skip_all, name = "storage::gallery_delete", fields(user_id = %user.id, %target_type, %image_id))] pub(super) async fn gallery_delete( State(db): State, AuthUser(user): AuthUser, Path((target_type, image_id)): Path<(String, ImageId)>, ) -> Result { user.check_not_suspended()?; let target = GalleryTarget::parse(&target_type)?; // Delete the row (ownership-scoped) + decrement storage + enqueue the S3 // deletion, all in one tx; the row returns its s3_key + size so we never // probe S3 for the decrement. The enqueue lives INSIDE the tx so a crash // between commit and a post-commit enqueue can't orphan the object with no // durable record (Run #18 Storage B6, matches delete_version / media_delete). let deleted: Option = { let mut tx = db.begin().await?; let row = match target { GalleryTarget::Item => { db::gallery_images::delete_for_item(&mut *tx, image_id, user.id).await? } GalleryTarget::Project => { db::gallery_images::delete_for_project(&mut *tx, image_id, user.id).await? } }; if let Some(ref r) = row { db::creator_tiers::decrement_storage_used(&mut *tx, user.id, r.file_size_bytes).await?; db::pending_s3_deletions::enqueue_deletions( &mut *tx, // Gallery images (item_images/project_images) are CDN-served; the // key may be a private staging key or a public content key, so // enqueue both (see `both_bucket_delete`). &crate::storage::both_bucket_delete(&r.s3_key), "gallery_image_delete", ) .await?; tx.commit().await?; } // If row is None the tx drops (rolls back) untouched. row }; if deleted.is_none() { return Err(AppError::NotFound); } Ok(Json(serde_json::json!({ "success": true }))) } // Reorder #[derive(Debug, Deserialize)] pub(super) struct GalleryReorderRequest { pub target_type: String, pub target_id: Uuid, pub ordered_ids: Vec, } /// POST /api/gallery/reorder, set gallery display order. #[tracing::instrument(skip_all, name = "storage::gallery_reorder", fields(user_id = %user.id))] pub(super) async fn gallery_reorder( State(db): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let target = GalleryTarget::parse(&req.target_type)?; require_owned(&db, target, req.target_id, user.id).await?; match target { GalleryTarget::Item => { db::gallery_images::reorder_item(&db, ItemId::from(req.target_id), &req.ordered_ids) .await?; } GalleryTarget::Project => { db::gallery_images::reorder_project( &db, ProjectId::from(req.target_id), &req.ordered_ids, ) .await?; } } bump_target_cache(&db, target, req.target_id).await; Ok(Json(serde_json::json!({ "success": true }))) } /// Bump the public-page cache generation for the affected project (best-effort). async fn bump_target_cache(db: &PgPool, target: GalleryTarget, target_id: Uuid) { let project_id = match target { GalleryTarget::Project => Some(ProjectId::from(target_id)), GalleryTarget::Item => db::items::get_item_by_id(db, ItemId::from(target_id)) .await .ok() .flatten() .map(|i| i.project_id), }; if let Some(pid) = project_id && let Err(e) = db::projects::bump_cache_generation(db, pid).await { tracing::warn!(project_id = %pid, error = ?e, "failed to bump cache generation after gallery change"); } }