//! 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::{ extract::{Path, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ auth::AuthUser, db::{self, ItemId, ProjectId, UserId}, error::{AppError, Result, ResultExt}, storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, AppState, }; use super::{commit_upload, CommitTarget, PresignUploadResponse}; /// 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( state: &AppState, target: GalleryTarget, target_id: Uuid, user_id: UserId, ) -> Result<()> { let owner = match target { GalleryTarget::Item => db::items::get_item_owner(&state.db, ItemId::from(target_id)).await?, GalleryTarget::Project => db::projects::get_project_by_id(&state.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: Uuid, 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(state): State, AuthUser(user): AuthUser, Path((target_type, target_id)): Path<(String, Uuid)>, ) -> Result { let target = GalleryTarget::parse(&target_type)?; require_owned(&state, target, target_id, user.id).await?; let rows = match target { GalleryTarget::Item => db::gallery_images::list_for_item(&state.db, ItemId::from(target_id)).await?, GalleryTarget::Project => db::gallery_images::list_for_project(&state.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 struct GalleryPresignRequest { pub target_type: String, pub target_id: Uuid, pub file_name: String, pub content_type: String, } /// 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.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(&state, 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(&state.db, ItemId::from(req.target_id)).await?, GalleryTarget::Project => db::gallery_images::count_for_project(&state.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(&state.db, user.id, file_type).await?; // Per-image uuid keeps multiple gallery uploads from colliding. let image_uuid = Uuid::new_v4(); let s3_key = match target { GalleryTarget::Item => { S3Client::generate_item_gallery_key(user.id, ItemId::from(req.target_id), image_uuid, &req.file_name) } GalleryTarget::Project => { S3Client::generate_project_gallery_key(ProjectId::from(req.target_id), image_uuid, &req.file_name) } }; 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 gallery image")?; Ok(Json(PresignUploadResponse { upload_url, s3_key, expires_in, cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), max_file_bytes: None, })) } // --------------------------------------------------------------------------- // Confirm // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct GalleryConfirmRequest { pub target_type: String, pub target_id: Uuid, pub s3_key: String, #[serde(default)] pub alt: String, } #[derive(Debug, Serialize)] pub struct GalleryConfirmResponse { pub success: bool, pub id: Uuid, pub image_url: String, pub alt: String, } /// 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; let target = GalleryTarget::parse(&req.target_type)?; require_owned(&state, target, req.target_id, user.id).await?; // Key must live under this entity's gallery prefix (no cross-entity steal). let expected_prefix = match target { GalleryTarget::Item => format!("{}/{}/gallery/", user.id, req.target_id), GalleryTarget::Project => format!("projects/{}/gallery/", req.target_id), }; if !req.s3_key.starts_with(&expected_prefix) { return Err(AppError::BadRequest("Invalid upload key".to_string())); } 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() { 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) ))); } // Authoritative cap re-check (presign's was best-effort/UX). let count = match target { GalleryTarget::Item => db::gallery_images::count_for_item(&state.db, ItemId::from(req.target_id)).await?, GalleryTarget::Project => db::gallery_images::count_for_project(&state.db, ProjectId::from(req.target_id)).await?, }; if count >= db::gallery_images::MAX_GALLERY_IMAGES { s3.delete_object(&req.s3_key).await.ok(); 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(&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); } }; let image_url = storage::build_project_image_url( s3.as_ref(), state.config.cdn_base_url.as_deref(), &req.s3_key, ) .await?; 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. let committed: Result = async { let mut tx = state.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?; 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(id) } .await; let id = match committed { Ok(id) => id, Err(e) => { super::enqueue_s3_orphan(&state.db, &req.s3_key, "gallery_image_insert_failed").await; return Err(e); } }; db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?; // Scan enqueue AFTER the DB write commits (the chronic-ordering rule). commit_upload( &state, CommitTarget::GalleryImage(id), &req.s3_key, FileType::Cover, user.id, file_size_bytes, ) .await?; bump_target_cache(&state, 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(state): State, AuthUser(user): AuthUser, Path((target_type, image_id)): Path<(String, Uuid)>, ) -> Result { user.check_not_suspended()?; let target = GalleryTarget::parse(&target_type)?; // Delete the row (ownership-scoped) + decrement storage in one tx; the row // returns its s3_key + size so we never probe S3 for the decrement. let deleted: Option = { let mut tx = state.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?; tx.commit().await?; } // If row is None the tx drops (rolls back) untouched. row }; let Some(row) = deleted else { return Err(AppError::NotFound); }; if let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &[(row.s3_key.clone(), "main".to_string())], "gallery_image_delete", ) .await { tracing::warn!(key = %row.s3_key, error = ?e, "failed to enqueue deleted gallery image for S3 deletion"); } Ok(Json(serde_json::json!({ "success": true }))) } // --------------------------------------------------------------------------- // Reorder // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let target = GalleryTarget::parse(&req.target_type)?; require_owned(&state, target, req.target_id, user.id).await?; match target { GalleryTarget::Item => { db::gallery_images::reorder_item(&state.db, ItemId::from(req.target_id), &req.ordered_ids).await? } GalleryTarget::Project => { db::gallery_images::reorder_project(&state.db, ProjectId::from(req.target_id), &req.ordered_ids).await? } } bump_target_cache(&state, 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(state: &AppState, 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(&state.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(&state.db, pid).await { tracing::warn!(project_id = %pid, error = ?e, "failed to bump cache generation after gallery change"); } }