//! Content insertion API: reusable clip library + per-item placement management. use axum::{ Json, extract::{Path, State}, response::{Html, IntoResponse}, }; use serde::{Deserialize, Serialize}; use crate::{AppStorage, Scanning}; use sqlx::PgPool; use crate::{ auth::AuthUser, db::{self, ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId}, error::{AppError, Result, ResultExt}, helpers::htmx_toast_response, routes::storage::{CommitTarget, commit_upload}, storage::{FileType, S3Client}, templates::InsertionListTemplate, }; use super::verify_item_ownership; // Request/Response Types #[derive(Debug, Deserialize)] pub(super) struct InsertionPresignRequest { pub file_name: String, pub content_type: String, } #[derive(Debug, Serialize)] pub(super) struct InsertionPresignResponse { pub upload_url: String, pub s3_key: String, pub expires_in: u64, } #[derive(Debug, Deserialize)] pub(super) struct InsertionConfirmRequest { pub s3_key: String, pub title: String, pub duration_ms: i32, #[allow(dead_code)] // kept for client compat; real size fetched from S3 pub file_size: i64, pub mime_type: String, } #[derive(Debug, Serialize)] pub(super) struct InsertionResponse { pub id: ContentInsertionId, pub title: String, pub media_type: String, pub duration_ms: i32, pub file_size: i64, } #[derive(Debug, Deserialize)] pub(super) struct RenameInsertionRequest { pub title: String, } #[derive(Debug, Deserialize)] pub(super) struct CreatePlacementRequest { pub insertion_id: ContentInsertionId, pub position: InsertionPosition, pub offset_ms: Option, #[serde(default)] pub sort_order: i32, } // Insertion Library Handlers /// Generate a presigned URL for uploading an insertion clip. /// /// POST /api/users/me/insertions/presign #[tracing::instrument(skip_all, name = "insertions::presign")] pub(super) async fn presign_insertion( State(db): State, State(storage): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = storage.require_s3()?; S3Client::validate_content_type(FileType::Insertion, &req.content_type)?; S3Client::validate_extension(FileType::Insertion, &req.file_name)?; // Check storage quota before issuing presigned URL db::creator_tiers::check_presign_allowed(&db, user.id, FileType::Insertion).await?; // Staging key (unserved); the scan worker promotes it to the content key on a // Clean verdict (C1). Insertion clips are served presigned from the stored // key, so promotion only repoints `content_insertions.storage_key`. let s3_key = S3Client::generate_staging_key(&req.file_name); // Track the pending upload so the reaper can clean it up if never confirmed 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(crate::storage::CACHE_CONTROL_IMMUTABLE), None, ) .await .context("presign upload for insertion clip")?; Ok(Json(InsertionPresignResponse { upload_url, s3_key: s3_key.into_string(), expires_in, })) } /// Confirm an insertion upload and create the DB record. /// /// POST /api/users/me/insertions/confirm #[tracing::instrument(skip_all, name = "insertions::confirm")] pub(super) async fn confirm_insertion( State(db): State, State(storage): State, State(scanning): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = storage.require_s3()?; // Ownership of the staging key is proved below (after the replay // short-circuit) via `pending_uploads`, a `staging/{uuid}` key carries no // user in its path for a prefix check to bind against. // Idempotent replay: a retried confirm for the same key must not // re-charge storage or insert a duplicate row (`storage_key` has no UNIQUE). // Return the already-created insertion (ultra-fuzz Run 12 Storage: confirm // idempotency). if let Some(existing) = db::content_insertions::get_insertion_by_storage_key(&db, user.id, &req.s3_key).await? { return Ok(Json(InsertionResponse { id: existing.id, title: existing.title, media_type: existing.media_type, duration_ms: existing.duration_ms, file_size: existing.file_size, })); } // Authorize the staging key for a fresh confirm: the caller must have // presigned it (recorded against them in `pending_uploads`). Placed after the // replay short-circuit, which is authorized by the row already existing, // and before the size/tier reject paths, so an unowned (at most another // user's in-flight) staging object is never enqueued for deletion. if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { return Err(AppError::BadRequest("Invalid upload key".to_string())); } // Get real file size from S3 (never trust client-provided size) let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| { AppError::BadRequest("Upload not found. Please try uploading again.".to_string()) })?; // Enforce static per-type size limit if file_size_bytes as u64 > FileType::Insertion.max_size() { crate::routes::storage::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected", ) .await; return Err(AppError::BadRequest(format!( "File exceeds maximum size of {} MB", FileType::Insertion.max_size() / (1024 * 1024) ))); } // Validate mime_type at confirm time (client could change it after presign) S3Client::validate_content_type(FileType::Insertion, &req.mime_type)?; if req.title.is_empty() || req.title.len() > 200 { return Err(AppError::BadRequest( "Title must be 1-200 characters".to_string(), )); } if req.duration_ms <= 0 { return Err(AppError::BadRequest( "Duration must be positive".to_string(), )); } // Enforce tier-based limits (per-file + storage cap) let max_storage = match db::creator_tiers::check_upload_allowed( &db, user.id, FileType::Insertion, file_size_bytes, ) .await { Ok(max) => max, Err(e) => { crate::routes::storage::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected", ) .await; return Err(e); } }; // Atomically increment storage BEFORE writing the DB record. // Avoids orphaned unbilled file references. if let Err(e) = db::creator_tiers::try_increment_storage(&db, user.id, file_size_bytes, max_storage).await { crate::routes::storage::enqueue_s3_orphan( &db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected", ) .await; return Err(e); } // Clear the pending upload record now that the upload is confirmed db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; // Persist the real media family (audio vs video) derived from the validated // MIME, rather than assuming audio. Drives the placement type-compat guard // and which player element the clip renders in. let media_type = S3Client::insertion_media_type(&req.mime_type); let insertion = db::content_insertions::create_insertion( &db, user.id, &req.title, media_type, &req.s3_key, req.duration_ms, file_size_bytes, &req.mime_type, ) .await?; // Scan AFTER the insertion row is created, same ordering rule that the // storage handlers follow. The row starts scan_status='pending' (fail-closed // gate): fan playback (list_playable_placements_for_item) hides it until the // worker flips it to 'clean'; on quarantine the row is purged and a WAM ticket // filed. The creator still sees the pending clip in their management library. commit_upload( &db, scanning.scanner.as_ref(), CommitTarget::ContentInsertion(insertion.id), &req.s3_key, FileType::Insertion, user.id, file_size_bytes, ) .await?; tracing::info!( "Insertion confirmed: id={}, user={}, key={}", insertion.id, user.id, req.s3_key ); Ok(Json(InsertionResponse { id: insertion.id, title: insertion.title, media_type: insertion.media_type, duration_ms: insertion.duration_ms, file_size: insertion.file_size, })) } /// List all insertion clips for the current user (HTMX partial). /// /// GET /api/users/me/insertions #[tracing::instrument(skip_all, name = "insertions::list")] pub(super) async fn list_insertions( State(db): State, AuthUser(user): AuthUser, ) -> Result { let insertions = db::content_insertions::list_insertions(&db, user.id).await?; let display: Vec = insertions .iter() .map(|i| crate::templates::InsertionDisplay { id: i.id.to_string(), title: i.title.clone(), media_type: i.media_type.clone(), duration_display: format_duration_ms(i.duration_ms), created_at: i.created_at.format("%Y-%m-%d").to_string(), }) .collect(); Ok(Html(crate::helpers::render_fragment( &InsertionListTemplate { insertions: display, }, )?)) } /// Rename an insertion clip. /// /// PUT /api/insertions/{id} #[tracing::instrument(skip_all, name = "insertions::rename")] pub(super) async fn rename_insertion( State(db): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; if req.title.is_empty() || req.title.len() > 200 { return Err(AppError::BadRequest( "Title must be 1-200 characters".to_string(), )); } let updated = db::content_insertions::update_insertion_title(&db, id, user.id, &req.title).await?; if !updated { return Err(AppError::NotFound); } Ok(htmx_toast_response("Clip renamed", "success")) } /// Delete an insertion clip (cascades placements). /// /// DELETE /api/insertions/{id} #[tracing::instrument(skip_all, name = "insertions::delete")] pub(super) async fn delete_insertion( State(db): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; // Look up the insertion for S3 cleanup and storage decrement let insertion = db::content_insertions::get_insertion(&db, id, user.id).await?; let file_size = insertion.as_ref().map_or(0, |i| i.file_size); // Enqueue as the sole durable deletion path, BEFORE the row delete. Abort on // enqueue failure rather than warn-and-proceed: deleting the row anyway would // orphan the clip's S3 object with no record (ultra-fuzz Run 12 Storage F3). // The reaper's is_s3_key_live guard makes the reverse case safe. if let Some(ref ins) = insertion { db::pending_s3_deletions::enqueue_deletions( &db, &[(ins.storage_key.clone(), "main".to_string())], "insertion_delete", ) .await?; } let deleted = db::content_insertions::delete_insertion(&db, id, user.id).await?; if !deleted { return Err(AppError::NotFound); } // Decrement storage counter if file_size > 0 { db::creator_tiers::decrement_storage_used(&db, user.id, file_size).await?; } Ok(htmx_toast_response("Clip deleted", "success")) } // Placement Handlers /// List placements for an item (HTMX partial). /// /// GET /api/items/{id}/insertions #[tracing::instrument(skip_all, name = "insertions::list_placements")] pub(super) async fn list_placements( State(db): State, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?; let placements = db::content_insertions::list_placements_for_item(&db, item_id).await?; // Only offer clips that can legally be placed on this item (a video clip on an // audio item would be rejected by create_placement, so don't surface it). let available: Vec<_> = db::content_insertions::list_insertions(&db, user.id) .await? .into_iter() .filter(|i| clip_compatible_with_item(&i.media_type, item.item_type)) .collect(); let placement_display: Vec = placements .iter() .map(|p| crate::templates::PlacementDisplay { id: p.id.to_string(), insertion_title: p.insertion_title.clone(), position: p.position.to_string(), offset_display: p.offset_ms.map(format_duration_ms), sort_order: p.sort_order, }) .collect(); let insertion_display: Vec = available .iter() .map(|i| crate::templates::InsertionDisplay { id: i.id.to_string(), title: i.title.clone(), media_type: i.media_type.clone(), duration_display: format_duration_ms(i.duration_ms), created_at: i.created_at.format("%Y-%m-%d").to_string(), }) .collect(); Ok(Html(crate::helpers::render_fragment( &crate::templates::PlacementListTemplate { item_id: item_id.to_string(), placements: placement_display, available_insertions: insertion_display, }, )?)) } /// Create a placement (attach an insertion to an item). /// /// POST /api/items/{id}/insertions #[tracing::instrument(skip_all, name = "insertions::create_placement")] pub(super) async fn create_placement( State(db): State, AuthUser(user): AuthUser, Path(item_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?; // Verify the insertion belongs to this user let insertion = db::content_insertions::get_insertion(&db, req.insertion_id, user.id) .await? .ok_or(AppError::NotFound)?; // Only audio/video items host a media player, so only they can carry clips. // A video clip additionally requires a video item, an audio item renders in // an `