//! Content insertion API: reusable clip library + per-item placement management. use askama::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse}, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId}, error::{AppError, Result, ResultExt}, helpers::htmx_toast_response, routes::storage::{commit_upload, CommitTarget}, storage::{FileType, S3Client}, templates::InsertionListTemplate, AppState, }; use super::verify_item_ownership; // ============================================================================= // Request/Response Types // ============================================================================= #[derive(Debug, Deserialize)] pub struct InsertionPresignRequest { pub file_name: String, pub content_type: String, } #[derive(Debug, Serialize)] pub struct InsertionPresignResponse { pub upload_url: String, pub s3_key: String, pub expires_in: u64, } #[derive(Debug, Deserialize)] pub 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 struct InsertionResponse { pub id: ContentInsertionId, pub title: String, pub media_type: String, pub duration_ms: i32, pub file_size: i64, } #[derive(Debug, Deserialize)] pub struct RenameInsertionRequest { pub title: String, } #[derive(Debug, Deserialize)] pub 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.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(&state.db, user.id, FileType::Insertion).await?; let s3_key = S3Client::generate_insertion_key(user.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(crate::storage::CACHE_CONTROL_IMMUTABLE), None) .await .context("presign upload for insertion clip")?; Ok(Json(InsertionPresignResponse { upload_url, s3_key, 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(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; let s3 = state.require_s3()?; // Validate S3 key belongs to this user (prevent cross-user file reference) let expected_prefix = format!("{}/insertions/", user.id); if !req.s3_key.starts_with(&expected_prefix) { 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() { s3.delete_object(&req.s3_key).await.ok(); 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(&state.db, user.id, FileType::Insertion, file_size_bytes).await { Ok(max) => max, Err(e) => { s3.delete_object(&req.s3_key).await.ok(); 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(&state.db, user.id, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } // 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?; let insertion = db::content_insertions::create_insertion( &state.db, user.id, &req.title, "audio", &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. No per-row scan_status column for insertions; // worker logs + creates a WAM ticket on quarantine for admin follow-up. commit_upload( &state, 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(state): State, AuthUser(user): AuthUser, ) -> Result { let insertions = db::content_insertions::list_insertions(&state.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( InsertionListTemplate { insertions: display } .render() .unwrap_or_default(), )) } /// Rename an insertion clip. /// /// PUT /api/insertions/{id} #[tracing::instrument(skip_all, name = "insertions::rename")] pub(super) async fn rename_insertion( State(state): 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( &state.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(state): 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(&state.db, id, user.id).await?; let file_size = insertion.as_ref().map(|i| i.file_size).unwrap_or(0); // Enqueue as a durable safety net if let Some(ref ins) = insertion && let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &[(ins.storage_key.clone(), "main".to_string())], "insertion_delete", ).await { tracing::warn!(error = ?e, "failed to enqueue S3 deletion for insertion"); } // Optionally delete the S3 object (best-effort) if let Some(ref s3) = state.s3 && let Some(ref ins) = insertion { let _ = s3.delete_object(&ins.storage_key).await; } let deleted = db::content_insertions::delete_insertion(&state.db, id, user.id).await?; if !deleted { return Err(AppError::NotFound); } // Decrement storage counter if file_size > 0 { db::creator_tiers::decrement_storage_used(&state.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(state): State, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { verify_item_ownership(&state, item_id, user.id).await?; let placements = db::content_insertions::list_placements_for_item(&state.db, item_id).await?; let available = db::content_insertions::list_insertions(&state.db, user.id).await?; 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::templates::PlacementListTemplate { item_id: item_id.to_string(), placements: placement_display, available_insertions: insertion_display, } .render() .unwrap_or_default(), )) } /// 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(state): State, AuthUser(user): AuthUser, Path(item_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_item_ownership(&state, item_id, user.id).await?; // Verify the insertion belongs to this user let insertion = db::content_insertions::get_insertion(&state.db, req.insertion_id, user.id) .await? .ok_or(AppError::NotFound)?; // Validate mid-roll offset if req.position == InsertionPosition::MidRoll && req.offset_ms.is_none() { return Err(AppError::BadRequest( "Mid-roll clips require an offset".to_string(), )); } let _placement = db::content_insertions::create_placement( &state.db, item_id, insertion.id, req.position, req.offset_ms, req.sort_order, ) .await?; Ok(htmx_toast_response("Clip added", "success")) } /// Remove a placement. /// /// DELETE /api/item-insertions/{id} #[tracing::instrument(skip_all, name = "insertions::delete_placement")] pub(super) async fn delete_placement( State(state): State, AuthUser(user): AuthUser, Path(placement_id): Path, ) -> Result { user.check_not_suspended()?; // Get placement to verify item ownership let placement = db::content_insertions::get_placement_by_id(&state.db, placement_id) .await? .ok_or(AppError::NotFound)?; verify_item_ownership(&state, placement.item_id, user.id).await?; db::content_insertions::delete_placement(&state.db, placement_id).await?; Ok(htmx_toast_response("Clip removed", "success")) } // ============================================================================= // Helpers // ============================================================================= /// Format milliseconds as MM:SS. fn format_duration_ms(ms: i32) -> String { let total_secs = ms / 1000; let mins = total_secs / 60; let secs = total_secs % 60; format!("{}:{:02}", mins, secs) } #[cfg(test)] mod tests { use super::*; #[test] fn zero_ms() { assert_eq!(format_duration_ms(0), "0:00"); } #[test] fn one_second() { assert_eq!(format_duration_ms(1000), "0:01"); } #[test] fn fifty_nine_seconds() { assert_eq!(format_duration_ms(59000), "0:59"); } #[test] fn one_minute() { assert_eq!(format_duration_ms(60000), "1:00"); } #[test] fn one_minute_one_second() { assert_eq!(format_duration_ms(61000), "1:01"); } #[test] fn over_sixty_minutes() { assert_eq!(format_duration_ms(3661000), "61:01"); } #[test] fn sub_second_rounds_down() { assert_eq!(format_duration_ms(500), "0:00"); } #[test] fn negative_ms_rounds_toward_zero() { // Rust integer division truncates toward zero: // -1500 / 1000 = -1, -1 / 60 = 0, -1 % 60 = -1 assert_eq!(format_duration_ms(-1500), "0:-1"); } }