//! Presigned upload and confirm handlers for version files. use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use serde::Deserialize; use crate::{ auth::AuthUser, db::{self, VersionId}, error::{AppError, Result, ResultExt}, storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, AppState, }; use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse}; /// JSON input for requesting a presigned version upload URL. #[derive(Debug, Deserialize)] pub struct VersionPresignRequest { pub file_name: String, pub content_type: String, } /// JSON input for confirming a completed version upload. #[derive(Debug, Deserialize)] pub struct VersionConfirmRequest { pub s3_key: String, } /// Generate a presigned URL for uploading a version file to S3 /// /// POST /api/versions/{version_id}/upload/presign /// /// Requires authentication. User must own the item (through version -> item -> project chain). #[tracing::instrument(skip_all, name = "storage::version_presign_upload", fields(%version_id, user_id = %user.id))] pub(super) async fn version_presign_upload( State(state): State, AuthUser(user): AuthUser, Path(version_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; let file_type = FileType::Download; // Validate content type and extension S3Client::validate_content_type(file_type, &req.content_type)?; S3Client::validate_extension(file_type, &req.file_name)?; // Fetch version and verify ownership through version -> item -> project chain let version = db::versions::get_version_by_id(&state.db, version_id) .await? .ok_or(AppError::NotFound)?; let owner = db::items::get_item_owner(&state.db, version.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 max_file_bytes = db::creator_tiers::get_effective_max_file_bytes(&state.db, user.id, file_type).await?; // Generate S3 key using the version's item_id let s3_key = S3Client::generate_key(user.id, version.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 version file")?; Ok(Json(PresignUploadResponse { upload_url, s3_key, expires_in, cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), max_file_bytes, })) } /// Confirm that a version file upload has completed and update the database /// /// POST /api/versions/{version_id}/upload/confirm /// /// Requires authentication. User must own the item. #[tracing::instrument(skip_all, name = "storage::version_confirm_upload", fields(%version_id, user_id = %user.id))] pub(super) async fn version_confirm_upload( State(state): State, AuthUser(user): AuthUser, Path(version_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; // Fetch version and verify ownership let version = db::versions::get_version_by_id(&state.db, version_id) .await? .ok_or(AppError::NotFound)?; let owner = db::items::get_item_owner(&state.db, version.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, version.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 (versions are always downloads) 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::Download.max_size() { s3.delete_object(&req.s3_key).await.ok(); let limit_mb = FileType::Download.max_size() / (1024 * 1024); let file_mb = file_size_bytes as u64 / (1024 * 1024); return Err(AppError::FileTooLarge(format!( "File is {} MB but the maximum for download files is {} MB.", file_mb, limit_mb ))); } // Enforce tier-based limits (per-file + storage cap) let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Download, file_size_bytes).await { Ok(max) => max, Err(e) => { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } }; // Idempotency: if the version already has this exact s3_key, return success (no-op). // Must come BEFORE scan enqueue / scan_status flip — re-confirming an already-Clean // version must not knock it back to Pending. if version.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(ConfirmUploadResponse { success: true, pending_review: None })); } let old_s3_key = version.s3_key.clone(); let old_size = version.file_size_bytes.unwrap_or(0); let is_replace = old_s3_key.is_some() && old_size > 0; // Extract file name from the s3_key (last path segment) let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string()); // Storage credit + version UPDATE in ONE transaction. The expected-old guard // (`s3_key IS NOT DISTINCT FROM`) returns no row if another confirm raced // ahead; we leave the tx uncommitted so the rollback undoes the storage // change with no compensating math (the previous swallowed-`.ok()` path). // `commit_upload` stays AFTER the commit (the blessed scan-ordering path). // `Ok(false)` = lost race (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, is_replace.then_some(old_size), file_size_bytes, max_storage, ).await?; let updated = db::versions::update_version_file( &mut *tx, version_id, old_s3_key.as_deref(), &req.s3_key, Some(file_size_bytes), file_name.as_deref(), ) .await?; if updated.is_none() { // Lost race — drop tx to roll back the storage change. return Ok(false); } tx.commit().await?; Ok(true) } .await; match committed { Err(e) => { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } Ok(false) => { s3.delete_object(&req.s3_key).await.ok(); return Err(AppError::BadRequest( "Version 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?; let scan_status = commit_upload( &state, CommitTarget::Version(version_id), &req.s3_key, FileType::Download, user.id, file_size_bytes, ).await?; // Enqueue old S3 key for deletion now that the DB record points to the new key if let Some(old_key) = old_s3_key && let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &[(old_key, "main".to_string())], "version_replace", ).await { tracing::warn!(error = ?e, "failed to enqueue old version S3 key for deletion"); } tracing::info!( key = %req.s3_key, size = file_size_bytes, is_replace, ?scan_status, "version upload confirmed" ); let pending_review = if scan_status == db::FileScanStatus::HeldForReview { Some(true) } else { None }; Ok(Json(ConfirmUploadResponse { success: true, pending_review })) }