Skip to main content

max / makenotwork

15.0 KB · 489 lines History Blame Raw
1 //! Content insertion API: reusable clip library + per-item placement management.
2
3 use askama::Template;
4 use axum::{
5 extract::{Path, State},
6 response::{Html, IntoResponse},
7 Json,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId},
14 error::{AppError, Result, ResultExt},
15 helpers::htmx_toast_response,
16 routes::storage::{commit_upload, CommitTarget},
17 storage::{FileType, S3Client},
18 templates::InsertionListTemplate,
19 AppState,
20 };
21
22 use super::verify_item_ownership;
23
24 // =============================================================================
25 // Request/Response Types
26 // =============================================================================
27
28 #[derive(Debug, Deserialize)]
29 pub struct InsertionPresignRequest {
30 pub file_name: String,
31 pub content_type: String,
32 }
33
34 #[derive(Debug, Serialize)]
35 pub struct InsertionPresignResponse {
36 pub upload_url: String,
37 pub s3_key: String,
38 pub expires_in: u64,
39 }
40
41 #[derive(Debug, Deserialize)]
42 pub struct InsertionConfirmRequest {
43 pub s3_key: String,
44 pub title: String,
45 pub duration_ms: i32,
46 #[allow(dead_code)] // kept for client compat; real size fetched from S3
47 pub file_size: i64,
48 pub mime_type: String,
49 }
50
51 #[derive(Debug, Serialize)]
52 pub struct InsertionResponse {
53 pub id: ContentInsertionId,
54 pub title: String,
55 pub media_type: String,
56 pub duration_ms: i32,
57 pub file_size: i64,
58 }
59
60 #[derive(Debug, Deserialize)]
61 pub struct RenameInsertionRequest {
62 pub title: String,
63 }
64
65 #[derive(Debug, Deserialize)]
66 pub struct CreatePlacementRequest {
67 pub insertion_id: ContentInsertionId,
68 pub position: InsertionPosition,
69 pub offset_ms: Option<i32>,
70 #[serde(default)]
71 pub sort_order: i32,
72 }
73
74 // =============================================================================
75 // Insertion Library Handlers
76 // =============================================================================
77
78 /// Generate a presigned URL for uploading an insertion clip.
79 ///
80 /// POST /api/users/me/insertions/presign
81 #[tracing::instrument(skip_all, name = "insertions::presign")]
82 pub(super) async fn presign_insertion(
83 State(state): State<AppState>,
84 AuthUser(user): AuthUser,
85 Json(req): Json<InsertionPresignRequest>,
86 ) -> Result<impl IntoResponse> {
87 user.check_not_suspended()?;
88 let s3 = state.require_s3()?;
89
90 S3Client::validate_content_type(FileType::Insertion, &req.content_type)?;
91 S3Client::validate_extension(FileType::Insertion, &req.file_name)?;
92
93 // Check storage quota before issuing presigned URL
94 db::creator_tiers::check_presign_allowed(&state.db, user.id, FileType::Insertion).await?;
95
96 let s3_key = S3Client::generate_insertion_key(user.id, &req.file_name);
97
98 // Track the pending upload so the reaper can clean it up if never confirmed
99 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
100
101 let expires_in = 3600;
102 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(crate::storage::CACHE_CONTROL_IMMUTABLE), None)
103 .await
104 .context("presign upload for insertion clip")?;
105
106 Ok(Json(InsertionPresignResponse {
107 upload_url,
108 s3_key,
109 expires_in,
110 }))
111 }
112
113 /// Confirm an insertion upload and create the DB record.
114 ///
115 /// POST /api/users/me/insertions/confirm
116 #[tracing::instrument(skip_all, name = "insertions::confirm")]
117 pub(super) async fn confirm_insertion(
118 State(state): State<AppState>,
119 AuthUser(user): AuthUser,
120 Json(req): Json<InsertionConfirmRequest>,
121 ) -> Result<impl IntoResponse> {
122 user.check_not_suspended()?;
123 let s3 = state.require_s3()?;
124
125 // Validate S3 key belongs to this user (prevent cross-user file reference)
126 let expected_prefix = format!("{}/insertions/", user.id);
127 if !req.s3_key.starts_with(&expected_prefix) {
128 return Err(AppError::BadRequest(
129 "Invalid upload key".to_string(),
130 ));
131 }
132
133 // Get real file size from S3 (never trust client-provided size)
134 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
135 AppError::BadRequest("Upload not found. Please try uploading again.".to_string())
136 })?;
137
138 // Enforce static per-type size limit
139 if file_size_bytes as u64 > FileType::Insertion.max_size() {
140 s3.delete_object(&req.s3_key).await.ok();
141 return Err(AppError::BadRequest(format!(
142 "File exceeds maximum size of {} MB",
143 FileType::Insertion.max_size() / (1024 * 1024)
144 )));
145 }
146
147 // Validate mime_type at confirm time (client could change it after presign)
148 S3Client::validate_content_type(FileType::Insertion, &req.mime_type)?;
149
150 if req.title.is_empty() || req.title.len() > 200 {
151 return Err(AppError::BadRequest("Title must be 1-200 characters".to_string()));
152 }
153 if req.duration_ms <= 0 {
154 return Err(AppError::BadRequest("Duration must be positive".to_string()));
155 }
156
157 // Enforce tier-based limits (per-file + storage cap)
158 let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Insertion, file_size_bytes).await {
159 Ok(max) => max,
160 Err(e) => {
161 s3.delete_object(&req.s3_key).await.ok();
162 return Err(e);
163 }
164 };
165
166 // Atomically increment storage BEFORE writing the DB record.
167 // Avoids orphaned unbilled file references.
168 if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await {
169 s3.delete_object(&req.s3_key).await.ok();
170 return Err(e);
171 }
172
173 // Clear the pending upload record now that the upload is confirmed
174 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
175
176 let insertion = db::content_insertions::create_insertion(
177 &state.db,
178 user.id,
179 &req.title,
180 "audio",
181 &req.s3_key,
182 req.duration_ms,
183 file_size_bytes,
184 &req.mime_type,
185 )
186 .await?;
187
188 // Scan AFTER the insertion row is created — same ordering rule that the
189 // storage handlers follow. No per-row scan_status column for insertions;
190 // worker logs + creates a WAM ticket on quarantine for admin follow-up.
191 commit_upload(
192 &state,
193 CommitTarget::ContentInsertion(insertion.id),
194 &req.s3_key,
195 FileType::Insertion,
196 user.id,
197 file_size_bytes,
198 ).await?;
199
200 tracing::info!(
201 "Insertion confirmed: id={}, user={}, key={}",
202 insertion.id,
203 user.id,
204 req.s3_key
205 );
206
207 Ok(Json(InsertionResponse {
208 id: insertion.id,
209 title: insertion.title,
210 media_type: insertion.media_type,
211 duration_ms: insertion.duration_ms,
212 file_size: insertion.file_size,
213 }))
214 }
215
216 /// List all insertion clips for the current user (HTMX partial).
217 ///
218 /// GET /api/users/me/insertions
219 #[tracing::instrument(skip_all, name = "insertions::list")]
220 pub(super) async fn list_insertions(
221 State(state): State<AppState>,
222 AuthUser(user): AuthUser,
223 ) -> Result<impl IntoResponse> {
224 let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?;
225
226 let display: Vec<crate::templates::InsertionDisplay> = insertions
227 .iter()
228 .map(|i| crate::templates::InsertionDisplay {
229 id: i.id.to_string(),
230 title: i.title.clone(),
231 media_type: i.media_type.clone(),
232 duration_display: format_duration_ms(i.duration_ms),
233 created_at: i.created_at.format("%Y-%m-%d").to_string(),
234 })
235 .collect();
236
237 Ok(Html(
238 InsertionListTemplate { insertions: display }
239 .render()
240 .unwrap_or_default(),
241 ))
242 }
243
244 /// Rename an insertion clip.
245 ///
246 /// PUT /api/insertions/{id}
247 #[tracing::instrument(skip_all, name = "insertions::rename")]
248 pub(super) async fn rename_insertion(
249 State(state): State<AppState>,
250 AuthUser(user): AuthUser,
251 Path(id): Path<ContentInsertionId>,
252 Json(req): Json<RenameInsertionRequest>,
253 ) -> Result<impl IntoResponse> {
254 user.check_not_suspended()?;
255 if req.title.is_empty() || req.title.len() > 200 {
256 return Err(AppError::BadRequest("Title must be 1-200 characters".to_string()));
257 }
258
259 let updated = db::content_insertions::update_insertion_title(
260 &state.db,
261 id,
262 user.id,
263 &req.title,
264 )
265 .await?;
266
267 if !updated {
268 return Err(AppError::NotFound);
269 }
270
271 Ok(htmx_toast_response("Clip renamed", "success"))
272 }
273
274 /// Delete an insertion clip (cascades placements).
275 ///
276 /// DELETE /api/insertions/{id}
277 #[tracing::instrument(skip_all, name = "insertions::delete")]
278 pub(super) async fn delete_insertion(
279 State(state): State<AppState>,
280 AuthUser(user): AuthUser,
281 Path(id): Path<ContentInsertionId>,
282 ) -> Result<impl IntoResponse> {
283 user.check_not_suspended()?;
284 // Look up the insertion for S3 cleanup and storage decrement
285 let insertion = db::content_insertions::get_insertion(&state.db, id, user.id).await?;
286 let file_size = insertion.as_ref().map(|i| i.file_size).unwrap_or(0);
287
288 // Enqueue as a durable safety net
289 if let Some(ref ins) = insertion
290 && let Err(e) = db::pending_s3_deletions::enqueue_deletions(
291 &state.db,
292 &[(ins.storage_key.clone(), "main".to_string())],
293 "insertion_delete",
294 ).await
295 {
296 tracing::warn!(error = ?e, "failed to enqueue S3 deletion for insertion");
297 }
298
299 // Optionally delete the S3 object (best-effort)
300 if let Some(ref s3) = state.s3
301 && let Some(ref ins) = insertion
302 {
303 let _ = s3.delete_object(&ins.storage_key).await;
304 }
305
306 let deleted = db::content_insertions::delete_insertion(&state.db, id, user.id).await?;
307
308 if !deleted {
309 return Err(AppError::NotFound);
310 }
311
312 // Decrement storage counter
313 if file_size > 0 {
314 db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size).await?;
315 }
316
317 Ok(htmx_toast_response("Clip deleted", "success"))
318 }
319
320 // =============================================================================
321 // Placement Handlers
322 // =============================================================================
323
324 /// List placements for an item (HTMX partial).
325 ///
326 /// GET /api/items/{id}/insertions
327 #[tracing::instrument(skip_all, name = "insertions::list_placements")]
328 pub(super) async fn list_placements(
329 State(state): State<AppState>,
330 AuthUser(user): AuthUser,
331 Path(item_id): Path<ItemId>,
332 ) -> Result<impl IntoResponse> {
333 verify_item_ownership(&state, item_id, user.id).await?;
334
335 let placements = db::content_insertions::list_placements_for_item(&state.db, item_id).await?;
336 let available = db::content_insertions::list_insertions(&state.db, user.id).await?;
337
338 let placement_display: Vec<crate::templates::PlacementDisplay> = placements
339 .iter()
340 .map(|p| crate::templates::PlacementDisplay {
341 id: p.id.to_string(),
342 insertion_title: p.insertion_title.clone(),
343 position: p.position.to_string(),
344 offset_display: p.offset_ms.map(format_duration_ms),
345 sort_order: p.sort_order,
346 })
347 .collect();
348
349 let insertion_display: Vec<crate::templates::InsertionDisplay> = available
350 .iter()
351 .map(|i| crate::templates::InsertionDisplay {
352 id: i.id.to_string(),
353 title: i.title.clone(),
354 media_type: i.media_type.clone(),
355 duration_display: format_duration_ms(i.duration_ms),
356 created_at: i.created_at.format("%Y-%m-%d").to_string(),
357 })
358 .collect();
359
360 Ok(Html(
361 crate::templates::PlacementListTemplate {
362 item_id: item_id.to_string(),
363 placements: placement_display,
364 available_insertions: insertion_display,
365 }
366 .render()
367 .unwrap_or_default(),
368 ))
369 }
370
371 /// Create a placement (attach an insertion to an item).
372 ///
373 /// POST /api/items/{id}/insertions
374 #[tracing::instrument(skip_all, name = "insertions::create_placement")]
375 pub(super) async fn create_placement(
376 State(state): State<AppState>,
377 AuthUser(user): AuthUser,
378 Path(item_id): Path<ItemId>,
379 Json(req): Json<CreatePlacementRequest>,
380 ) -> Result<impl IntoResponse> {
381 user.check_not_suspended()?;
382 verify_item_ownership(&state, item_id, user.id).await?;
383
384 // Verify the insertion belongs to this user
385 let insertion = db::content_insertions::get_insertion(&state.db, req.insertion_id, user.id)
386 .await?
387 .ok_or(AppError::NotFound)?;
388
389 // Validate mid-roll offset
390 if req.position == InsertionPosition::MidRoll && req.offset_ms.is_none() {
391 return Err(AppError::BadRequest(
392 "Mid-roll clips require an offset".to_string(),
393 ));
394 }
395
396 let _placement = db::content_insertions::create_placement(
397 &state.db,
398 item_id,
399 insertion.id,
400 req.position,
401 req.offset_ms,
402 req.sort_order,
403 )
404 .await?;
405
406 Ok(htmx_toast_response("Clip added", "success"))
407 }
408
409 /// Remove a placement.
410 ///
411 /// DELETE /api/item-insertions/{id}
412 #[tracing::instrument(skip_all, name = "insertions::delete_placement")]
413 pub(super) async fn delete_placement(
414 State(state): State<AppState>,
415 AuthUser(user): AuthUser,
416 Path(placement_id): Path<ContentInsertionPlacementId>,
417 ) -> Result<impl IntoResponse> {
418 user.check_not_suspended()?;
419 // Get placement to verify item ownership
420 let placement = db::content_insertions::get_placement_by_id(&state.db, placement_id)
421 .await?
422 .ok_or(AppError::NotFound)?;
423
424 verify_item_ownership(&state, placement.item_id, user.id).await?;
425
426 db::content_insertions::delete_placement(&state.db, placement_id).await?;
427
428 Ok(htmx_toast_response("Clip removed", "success"))
429 }
430
431 // =============================================================================
432 // Helpers
433 // =============================================================================
434
435 /// Format milliseconds as MM:SS.
436 fn format_duration_ms(ms: i32) -> String {
437 let total_secs = ms / 1000;
438 let mins = total_secs / 60;
439 let secs = total_secs % 60;
440 format!("{}:{:02}", mins, secs)
441 }
442
443 #[cfg(test)]
444 mod tests {
445 use super::*;
446
447 #[test]
448 fn zero_ms() {
449 assert_eq!(format_duration_ms(0), "0:00");
450 }
451
452 #[test]
453 fn one_second() {
454 assert_eq!(format_duration_ms(1000), "0:01");
455 }
456
457 #[test]
458 fn fifty_nine_seconds() {
459 assert_eq!(format_duration_ms(59000), "0:59");
460 }
461
462 #[test]
463 fn one_minute() {
464 assert_eq!(format_duration_ms(60000), "1:00");
465 }
466
467 #[test]
468 fn one_minute_one_second() {
469 assert_eq!(format_duration_ms(61000), "1:01");
470 }
471
472 #[test]
473 fn over_sixty_minutes() {
474 assert_eq!(format_duration_ms(3661000), "61:01");
475 }
476
477 #[test]
478 fn sub_second_rounds_down() {
479 assert_eq!(format_duration_ms(500), "0:00");
480 }
481
482 #[test]
483 fn negative_ms_rounds_toward_zero() {
484 // Rust integer division truncates toward zero:
485 // -1500 / 1000 = -1, -1 / 60 = 0, -1 % 60 = -1
486 assert_eq!(format_duration_ms(-1500), "0:-1");
487 }
488 }
489