Skip to main content

max / makenotwork

29.6 KB · 785 lines History Blame Raw
1 //! Media library upload, listing, and deletion handlers.
2 //!
3 //! Provides a user-scoped media library for embedding images and videos
4 //! in markdown content (item bodies, sections, blog posts). Uploads land on a
5 //! random `staging/{uuid}` key; the scan worker promotes a clean object to its
6 //! content-hash key, and files are served via `cdn.makenot.work`. The
7 //! `(folder, filename)` pair is a logical name only, decoupled from the
8 //! physical key.
9
10 use axum::{
11 Json,
12 extract::{Path, Query, State},
13 response::IntoResponse,
14 };
15 use serde::{Deserialize, Serialize};
16 use sqlx::PgPool;
17
18 use crate::{
19 AppStorage, Scanning,
20 auth::AuthUser,
21 config::Config,
22 db::{self, MediaFileId},
23 error::{AppError, Result, ResultExt},
24 storage::{CACHE_CONTROL_IMMUTABLE, FileType, S3Client, sanitize_filename, sanitize_folder},
25 };
26
27 use super::{CommitTarget, ConfirmUploadResponse, PresignUploadResponse, commit_upload};
28
29 // Request / Response Types
30
31 #[derive(Debug, Deserialize)]
32 pub(crate) struct MediaPresignRequest {
33 pub file_name: String,
34 pub content_type: String,
35 #[serde(default)]
36 pub folder: String,
37 /// Optional declared size; when present it is signed into the presigned
38 /// URL's `Content-Length` so S3 rejects oversized PUTs at the protocol
39 /// level (media video is the largest upload class, up to 20 GB).
40 #[serde(default)]
41 pub file_size_bytes: Option<i64>,
42 }
43
44 #[derive(Debug, Deserialize)]
45 pub(crate) struct MediaConfirmRequest {
46 pub s3_key: String,
47 pub file_name: String,
48 pub content_type: String,
49 // The logical library name (folder + filename) is carried on the confirm.
50 // Under scan-then-promote the physical key is a content hash that no longer
51 // encodes the name, so there is nothing in the key for a client-supplied
52 // folder to disagree with (the Run #22 mismatch concern is gone), the
53 // `(user_id, folder, filename)` unique index guards the logical namespace,
54 // decoupled from the content-addressed object. Both are re-sanitized at
55 // confirm. Defaulted so a client omitting it lands the file in the root.
56 #[serde(default)]
57 pub folder: String,
58 }
59
60 #[derive(Debug, Deserialize)]
61 pub(crate) struct MediaListQuery {
62 pub folder: Option<String>,
63 }
64
65 #[derive(Debug, Serialize)]
66 pub(crate) struct MediaFileResponse {
67 pub id: MediaFileId,
68 pub folder: String,
69 pub filename: String,
70 pub content_type: String,
71 pub file_size_bytes: i64,
72 pub media_type: String,
73 pub cdn_url: String,
74 pub markdown_ref: String,
75 pub created_at: String,
76 }
77
78 #[derive(Debug, Serialize)]
79 pub(crate) struct MediaListResponse {
80 pub files: Vec<MediaFileResponse>,
81 pub folders: Vec<String>,
82 }
83
84 #[derive(Debug, Serialize)]
85 pub(crate) struct MediaFoldersResponse {
86 pub folders: Vec<String>,
87 }
88
89 // Helpers
90
91 /// Determine the media file type (image or video) from content type.
92 fn classify_media(content_type: &str) -> Result<(&'static str, FileType)> {
93 if content_type.starts_with("image/") {
94 Ok(("image", FileType::MediaImage))
95 } else if content_type.starts_with("video/") {
96 Ok(("video", FileType::MediaVideo))
97 } else {
98 Err(AppError::BadRequest(format!(
99 "Unsupported content type: {content_type}. Only images and videos are allowed."
100 )))
101 }
102 }
103
104 fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse {
105 let cdn_url = format!("{}/{}", cdn_base, f.s3_key);
106 // Folder-relative embed path, built from the logical name on the row, not
107 // the physical key, which under scan-then-promote is a content hash
108 // (`{user}/c/{sha}.ext`) that no longer encodes folder/filename.
109 let markdown_ref = if f.folder.is_empty() {
110 format!("![]({})", f.filename)
111 } else {
112 format!("![]({}/{})", f.folder, f.filename)
113 };
114 MediaFileResponse {
115 id: f.id,
116 folder: f.folder.clone(),
117 filename: f.filename.clone(),
118 content_type: f.content_type.clone(),
119 file_size_bytes: f.file_size_bytes,
120 media_type: f.media_type.clone(),
121 cdn_url,
122 markdown_ref,
123 created_at: f.created_at.to_rfc3339(),
124 }
125 }
126
127 // Handlers
128
129 /// Generate a presigned URL for uploading a media file.
130 ///
131 /// POST /api/media/presign
132 #[tracing::instrument(skip_all, name = "media::presign", fields(user_id = %user.id))]
133 pub(super) async fn media_presign(
134 State(db): State<PgPool>,
135 State(storage): State<AppStorage>,
136 AuthUser(user): AuthUser,
137 Json(req): Json<MediaPresignRequest>,
138 ) -> Result<impl IntoResponse> {
139 user.check_not_suspended()?;
140 let s3 = storage.require_s3()?;
141
142 let (media_type, file_type) = classify_media(&req.content_type)?;
143 let _ = media_type; // used at confirm time
144
145 // Validate content type and extension
146 S3Client::validate_content_type(file_type, &req.content_type)?;
147 S3Client::validate_extension(file_type, &req.file_name)?;
148
149 let folder = sanitize_folder(&req.folder);
150
151 // Check for path traversal in folder
152 if req.folder.contains("..") {
153 return Err(AppError::BadRequest("Invalid folder name".to_string()));
154 }
155
156 // Early quota check, images bypass tier, video requires BigFiles+
157 db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?;
158
159 // Validate the declared size against the static per-type cap AND the user's
160 // tier per-file cap before signing it into Content-Length, so an oversize
161 // media upload is rejected at presign instead of after the bytes are spent
162 // and only caught at confirm. `get_effective_max_file_bytes` returns None for
163 // images (they bypass the tier cap) and the tier limit for video.
164 let max_file_bytes =
165 db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?;
166 super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?;
167
168 // Filename uniqueness is enforced at confirm time by the
169 // `idx_media_files_user_folder_name` unique index, see `media_confirm`,
170 // which catches the duplicate-INSERT error, rolls back the storage
171 // credit, and returns the clean "already exists" message. It deliberately
172 // does NOT delete the S3 object on that path, see the 23505 branch there
173 // for why. The pre-check we used to do here at presign time was racy (two
174 // concurrent presigns both pass the SELECT, then both try to upload and
175 // one wastes bandwidth) and the confirm-time path is authoritative either
176 // way.
177
178 // Staging key (unserved); the scan worker promotes it to the content key on a
179 // Clean verdict (C1). The logical library name (folder + filename) is no
180 // longer encoded in the physical key, it is carried on the row and re-derived
181 // from the (sanitized) request at confirm.
182 let _ = &folder; // validated above for early rejection; not woven into the key
183 let s3_key = S3Client::generate_staging_key(&req.file_name);
184
185 // Track the pending upload so the reaper can clean it up if never confirmed
186 db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?;
187
188 let expires_in = 3600;
189 let upload_url = s3
190 .presign_upload(
191 &s3_key,
192 &req.content_type,
193 Some(expires_in),
194 Some(CACHE_CONTROL_IMMUTABLE),
195 req.file_size_bytes,
196 )
197 .await
198 .context("presign upload for media file")?;
199
200 Ok(Json(PresignUploadResponse {
201 upload_url,
202 s3_key: s3_key.into_string(),
203 expires_in,
204 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
205 max_file_bytes: None,
206 }))
207 }
208
209 /// Confirm a completed media file upload.
210 ///
211 /// POST /api/media/confirm
212 #[tracing::instrument(skip_all, name = "media::confirm", fields(user_id = %user.id))]
213 pub(super) async fn media_confirm(
214 State(db): State<PgPool>,
215 State(storage): State<AppStorage>,
216 State(scanning): State<Scanning>,
217 AuthUser(user): AuthUser,
218 Json(req): Json<MediaConfirmRequest>,
219 ) -> Result<impl IntoResponse> {
220 user.check_not_suspended()?;
221 let s3 = storage.require_s3()?;
222
223 let (media_type, file_type) = classify_media(&req.content_type)?;
224
225 // Re-validate content type and extension at confirm time (may differ from presign)
226 S3Client::validate_content_type(file_type, &req.content_type)?;
227 S3Client::validate_extension(file_type, &req.file_name)?;
228
229 // Authorize the staging key: a `staging/{uuid}` key has no user in its path,
230 // so ownership is proved via the `pending_uploads` row recorded at presign,
231 // not a prefix check. Gate before the sniff/size-reject paths so an unowned
232 // (at most another user's in-flight) staging object is never enqueued for
233 // deletion.
234 if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? {
235 return Err(AppError::BadRequest("Invalid upload key".to_string()));
236 }
237
238 // Verify the object exists in S3
239 if !s3.object_exists(&req.s3_key).await? {
240 return Err(AppError::BadRequest(
241 "Upload not found. Please try uploading again.".to_string(),
242 ));
243 }
244
245 // Get file size
246 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
247 AppError::BadRequest(
248 "Could not determine file size. Please try uploading again.".to_string(),
249 )
250 })?;
251 if file_size_bytes as u64 > file_type.max_size() {
252 super::enqueue_s3_orphan(
253 &db,
254 &req.s3_key,
255 crate::storage::S3Bucket::Main,
256 "media_upload_rejected",
257 )
258 .await;
259 return Err(AppError::BadRequest(format!(
260 "File exceeds maximum size of {} MB",
261 file_type.max_size() / (1024 * 1024)
262 )));
263 }
264
265 // Reconcile the real media category against the declared content_type before
266 // tier enforcement. The declared type is client-controlled, it is bound into
267 // the presigned PUT and merely echoed back by S3 metadata, so trusting it
268 // lets a video be declared `image/png` and dodge the BigFiles+ video-tier
269 // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is
270 // detectably a different audio/visual category than declared, reject + orphan.
271 {
272 // Ranged read of just the header, a 4 KB sniff must not transfer the
273 // whole (up to 20 GB) object. Production issues `Range: bytes=0-4095`.
274 let head = s3.download_head(&req.s3_key, 4096).await?;
275 let detected = infer::get(&head).map(|kind| kind.matcher_type());
276 // `media_type` is only ever "image" or "video" (see `classify_media`).
277 let mismatch = match media_type {
278 // Every allowed image format (jpeg/png/webp/gif) is positively
279 // detected by `infer`, so a declared image MUST sniff as an image.
280 // Requiring a positive image signature, rather than only rejecting
281 // a *detected* video, closes the bypass where a video `infer`
282 // cannot name its container is declared `image/png` to dodge the
283 // BigFiles+ video-tier gate (Run #22 / Run #5 Storage): a video
284 // never sniffs as Image, so it is rejected here either way.
285 "image" => detected != Some(infer::MatcherType::Image),
286 // Declared video: do NOT require a positive video signature,
287 // `infer` cannot classify every valid container (fragmented mp4,
288 // some mov/webm), and there is no tier-evasion incentive to declare
289 // a video as a video. Only reject a still image mislabeled as video.
290 "video" => detected == Some(infer::MatcherType::Image),
291 _ => false,
292 };
293 if mismatch {
294 super::enqueue_s3_orphan(
295 &db,
296 &req.s3_key,
297 crate::storage::S3Bucket::Main,
298 "media_content_type_mismatch",
299 )
300 .await;
301 let sniffed = match detected {
302 Some(infer::MatcherType::Image) => "image",
303 Some(infer::MatcherType::Video) => "video",
304 _ => "an unrecognized format",
305 };
306 return Err(AppError::BadRequest(format!(
307 "Uploaded file does not match the declared {media_type} type (detected: {sniffed})."
308 )));
309 }
310 }
311
312 // Tier enforcement
313 let max_storage =
314 match db::creator_tiers::check_upload_allowed(&db, user.id, file_type, file_size_bytes)
315 .await
316 {
317 Ok(max) => max,
318 Err(e) => {
319 super::enqueue_s3_orphan(
320 &db,
321 &req.s3_key,
322 crate::storage::S3Bucket::Main,
323 "media_upload_rejected",
324 )
325 .await;
326 return Err(e);
327 }
328 };
329
330 // Derive the logical library name (folder + filename) from the request,
331 // re-applying the same sanitizers presign used. Under scan-then-promote the
332 // physical key is a content hash that no longer encodes the name (the Run #22
333 // key-vs-name mismatch it guarded against is gone, the name is now a purely
334 // logical namespace, enforced by the `(user_id, folder, filename)` unique
335 // index, decoupled from the content-addressed object).
336 if req.folder.contains("..") {
337 return Err(AppError::BadRequest("Invalid folder name".to_string()));
338 }
339 let folder = sanitize_folder(&req.folder);
340 let safe_filename = sanitize_filename(&req.file_name);
341 if safe_filename.is_empty() {
342 return Err(AppError::BadRequest("Invalid file name".to_string()));
343 }
344
345 // Wrap storage credit + pending_uploads clear + media_files INSERT in a
346 // single transaction. The Run #5 audit flagged the previous non-atomic
347 // three-write sequence: a process interruption between writes could leave
348 // a charged storage counter with no row to refund against (storage credit
349 // leak), or a removed pending_uploads row with no media_files row + no
350 // tracker for the reaper (orphan S3 object + over-charge). With the tx,
351 // any rollback restores all three table states; only the S3 object needs
352 // explicit cleanup on failure.
353 //
354 // The unique index on (user_id, folder, filename) raises 23505 inside the
355 // tx; we catch the typed error after rollback and report a clean message.
356 let tx_result: Result<db::DbMediaFile> = async {
357 let mut tx = db.begin().await?;
358 db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage)
359 .await?;
360 db::pending_uploads::remove_pending_upload(&mut *tx, user.id, &req.s3_key, "main").await?;
361 let row = db::media_files::create(
362 &mut *tx,
363 user.id,
364 &folder,
365 &safe_filename,
366 &req.s3_key,
367 &req.content_type,
368 file_size_bytes,
369 media_type,
370 db::FileScanStatus::Pending.to_string().as_str(),
371 )
372 .await?;
373 tx.commit().await?;
374 Ok(row)
375 }
376 .await;
377
378 let inserted = match tx_result {
379 Ok(row) => row,
380 Err(e) => {
381 tracing::warn!(error = ?e, "media_confirm transaction failed");
382 // Detect the duplicate case via the structured Postgres SQLSTATE
383 // (23505). The previous `e.to_string()` substring check broke when
384 // the AppError wrapper changed how the inner sqlx error rendered.
385 if let AppError::Database(sqlx::Error::Database(db_err)) = &e
386 && db_err.code().as_deref() == Some("23505")
387 {
388 // Two different situations raise 23505 here and this branch
389 // cannot tell them apart, so it fails safe and never deletes
390 // (Run #11 HIGH):
391 //
392 // - A retried or concurrent confirm of THIS upload. The first
393 // confirm committed a row pointing at exactly this
394 // `req.s3_key`, so deleting the object would torpedo the
395 // file that live row serves.
396 // - A genuinely different upload that collides on
397 // (user, folder, filename). Staging keys are per-presign
398 // UUIDs, so this object is an orphan, but the tx rolled
399 // back and left its `pending_uploads` row intact, which is
400 // what the reaper collects it by.
401 //
402 // The tx already rolled back the storage charge either way.
403 // Reject the duplicate without touching S3.
404 return Err(AppError::BadRequest(format!(
405 "A file named '{}' already exists in folder '{}'.",
406 safe_filename,
407 if folder.is_empty() { "(root)" } else { &folder }
408 )));
409 }
410 // Any other failure: the tx rolled back and no row references this
411 // freshly-uploaded object, so it's a genuine orphan, clean it up.
412 super::enqueue_s3_orphan(
413 &db,
414 &req.s3_key,
415 crate::storage::S3Bucket::Main,
416 "media_upload_rejected",
417 )
418 .await;
419 return Err(e);
420 }
421 };
422
423 // Scan enqueue + scan_status flip AFTER the INSERT commits via the shared
424 // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner
425 // dev/test environment doesn't leave the row Pending forever.
426 let scan_status = commit_upload(
427 &db,
428 scanning.scanner.as_ref(),
429 CommitTarget::Media(inserted.id),
430 &req.s3_key,
431 file_type,
432 user.id,
433 file_size_bytes,
434 )
435 .await?;
436
437 tracing::info!(
438 "Media upload confirmed: user={}, folder={}, file={}, size={}",
439 user.id,
440 folder,
441 safe_filename,
442 file_size_bytes
443 );
444
445 let pending_review = if scan_status == db::FileScanStatus::HeldForReview {
446 Some(true)
447 } else {
448 None
449 };
450 Ok(Json(ConfirmUploadResponse {
451 success: true,
452 pending_review,
453 }))
454 }
455
456 /// List media files for the authenticated user.
457 ///
458 /// GET /api/media?folder={folder}
459 #[tracing::instrument(skip_all, name = "media::list", fields(user_id = %user.id))]
460 pub(super) async fn media_list(
461 State(db): State<PgPool>,
462 State(config): State<Config>,
463 AuthUser(user): AuthUser,
464 Query(query): Query<MediaListQuery>,
465 ) -> Result<impl IntoResponse> {
466 let cdn_base = config.cdn_base_url.as_str();
467
468 let files = db::media_files::list_by_user_folder(&db, user.id, query.folder.as_deref()).await?;
469
470 let folders = db::media_files::list_folders(&db, user.id).await?;
471
472 let file_responses: Vec<MediaFileResponse> = files
473 .iter()
474 .map(|f| file_to_response(f, cdn_base))
475 .collect();
476
477 Ok(Json(MediaListResponse {
478 files: file_responses,
479 folders,
480 }))
481 }
482
483 // The described picker
484 //
485 // Shape 4 of the conversion plan, finished 2026-08-22 once quasi 0.54.0 gave an
486 // act a destination field to deposit into. What these three answer is markup
487 // built by `crate::quasi::media_picker`; the JSON route above is untouched and
488 // still serves the Media Library tab.
489 //
490 // Here rather than beside the pages, because what a picker draws is this
491 // module's data and nothing else: the same two queries `media_list` makes, with
492 // the narrowing done in SQL instead of by hiding tiles in a browser.
493
494 /// What the picker's routes read out of the query string.
495 #[derive(Debug, Default, Deserialize)]
496 pub(crate) struct PickerQuery {
497 /// The `Field::name` of the editor a pick is deposited into.
498 ///
499 /// Carried on every action the picker emits: it is opened *for* a box, and
500 /// the shipped script held the same fact in a module-level variable.
501 #[serde(default)]
502 pub into: String,
503 /// The typed filter, matched against the file name.
504 #[serde(default, rename = "media-name")]
505 pub name: String,
506 /// The chosen folder, empty for all of them.
507 #[serde(default, rename = "media-folder")]
508 pub folder: String,
509 }
510
511 /// The picker, opened for one editor.
512 ///
513 /// GET /media/picker?into={field}
514 #[tracing::instrument(skip_all, name = "media::picker", fields(user_id = %user.id))]
515 pub(super) async fn media_picker(
516 State(db): State<PgPool>,
517 State(config): State<Config>,
518 AuthUser(user): AuthUser,
519 Query(query): Query<PickerQuery>,
520 ) -> Result<impl IntoResponse> {
521 picker_destination(&query)?;
522 let entries = picker_entries(&db, &config, &user, &query).await?;
523 let folders = db::media_files::list_folders(&db, user.id).await?;
524
525 Ok(axum::response::Html(crate::quasi::media_picker::picker(
526 &entries,
527 &folders,
528 &query.into,
529 &query.name,
530 &query.folder,
531 )))
532 }
533
534 /// The tiles alone, for a filter that moved.
535 ///
536 /// GET /media/picker/grid?into={field}&media-name={q}&media-folder={folder}
537 ///
538 /// The filters land as one answer aimed at the grid rather than at the modal,
539 /// so the box being typed into is not replaced under the caret.
540 #[tracing::instrument(skip_all, name = "media::picker_grid", fields(user_id = %user.id))]
541 pub(super) async fn media_picker_grid(
542 State(db): State<PgPool>,
543 State(config): State<Config>,
544 AuthUser(user): AuthUser,
545 Query(query): Query<PickerQuery>,
546 ) -> Result<impl IntoResponse> {
547 picker_destination(&query)?;
548 let entries = picker_entries(&db, &config, &user, &query).await?;
549 Ok(axum::response::Html(crate::quasi::media_picker::grid(
550 &entries,
551 &query.into,
552 )))
553 }
554
555 /// Put it away.
556 ///
557 /// GET /media/picker/close
558 ///
559 /// The empty region the page started with. Closed and never-opened are one
560 /// state, which is why there is nothing here to read: the shipped dismissal was
561 /// `display:none` plus a scrim click, and a region that is present and not shown
562 /// is not something the vocabulary says.
563 pub(super) async fn media_picker_close(
564 Query(query): Query<PickerQuery>,
565 ) -> Result<impl IntoResponse> {
566 // Gated like the other two, though the answer is an empty region either way:
567 // what it refuses is emitting an id somebody else chose.
568 picker_destination(&query)?;
569 Ok(axum::response::Html(crate::quasi::media_picker::dismissed(
570 &query.into,
571 )))
572 }
573
574 /// Refuse a destination this module will not build an id out of.
575 ///
576 /// `into` reaches here off a query string and leaves as an `id` and as the
577 /// inside of a `#`-selector. The emitter escapes both, so this is not what
578 /// stops an injection; it is what stops a well-formed selector meaning
579 /// something other than the element it names. Every field name in the tree is
580 /// already a plain handle, so nothing legitimate is refused.
581 fn picker_destination(query: &PickerQuery) -> Result<()> {
582 if crate::quasi::media_picker::addressable(&query.into) {
583 Ok(())
584 } else {
585 Err(AppError::BadRequest(
586 "Not a field this picker can write into".to_string(),
587 ))
588 }
589 }
590
591 /// The files a picker shows, narrowed by both filters.
592 ///
593 /// The folder narrows in SQL, which is the query `media_list` already makes; the
594 /// name narrows here, case-insensitively, because a substring match over one
595 /// reader's library is not worth a second query shape. The shipped picker did
596 /// the same match in a browser over the whole library, and did it against
597 /// `undefined`.
598 async fn picker_entries(
599 db: &PgPool,
600 config: &Config,
601 user: &crate::auth::SessionUser,
602 query: &PickerQuery,
603 ) -> Result<Vec<crate::quasi::media_picker::Entry>> {
604 let folder = (!query.folder.is_empty()).then_some(query.folder.as_str());
605 let files = db::media_files::list_by_user_folder(db, user.id, folder).await?;
606 let needle = query.name.trim().to_lowercase();
607
608 Ok(files
609 .iter()
610 .map(|file| file_to_response(file, config.cdn_base_url.as_str()))
611 .filter(|file| needle.is_empty() || file.filename.to_lowercase().contains(&needle))
612 .map(|file| crate::quasi::media_picker::Entry {
613 id: file.id.to_string(),
614 image: file.content_type.starts_with("image"),
615 filename: file.filename,
616 folder: file.folder,
617 // Verbatim. `markdown_ref` is already `![](folder/file.png)`, and
618 // wrapping it a second time is what the shipped script did.
619 reference: file.markdown_ref,
620 url: file.cdn_url,
621 })
622 .collect())
623 }
624
625 /// List distinct folder names for the authenticated user.
626 ///
627 /// GET /api/media/folders
628 #[tracing::instrument(skip_all, name = "media::folders", fields(user_id = %user.id))]
629 pub(super) async fn media_folders(
630 State(db): State<PgPool>,
631 AuthUser(user): AuthUser,
632 ) -> Result<impl IntoResponse> {
633 let folders = db::media_files::list_folders(&db, user.id).await?;
634 Ok(Json(MediaFoldersResponse { folders }))
635 }
636
637 /// Delete a media file.
638 ///
639 /// DELETE /api/media/{id}
640 #[tracing::instrument(skip_all, name = "media::delete", fields(user_id = %user.id, media_id = %id))]
641 pub(super) async fn media_delete(
642 State(db): State<PgPool>,
643 State(storage): State<AppStorage>,
644 AuthUser(user): AuthUser,
645 Path(id): Path<MediaFileId>,
646 ) -> Result<impl IntoResponse> {
647 user.check_not_suspended()?;
648 // Require S3 to be configured, but the actual delete goes through the
649 // durable queue (the sanctioned deletion path) rather than a direct call.
650 storage.require_s3()?;
651
652 let file = db::media_files::get_by_id(&db, id)
653 .await?
654 .ok_or(AppError::NotFound)?;
655
656 // Verify ownership
657 if file.user_id != user.id {
658 return Err(AppError::Forbidden);
659 }
660
661 // Refund ONLY when the DELETE actually removed a row: `get_by_id` above is
662 // outside the tx, so a concurrent double-delete (double-click / retry) can
663 // let both requests past it; gating the decrement on `delete(...).is_some()`
664 // stops the second one from decrementing storage a second time and
665 // under-counting `storage_used_bytes` in the creator's favor (Run #12 LOW,
666 // the delete-side mirror of the confirm handlers' rows-affected discipline).
667 // Row delete + storage refund + S3-deletion enqueue all in ONE transaction.
668 // Enqueueing inside the tx (rather than after commit) closes the crash window
669 // where a commit followed by a failed post-commit enqueue orphaned the object
670 // with no durable record (Run #18 Storage B6, the same in-tx ordering
671 // delete_version adopted). The refund + enqueue use the DELETE's own returned
672 // row (`deleted`), not the pre-tx `get_by_id` read.
673 let mut tx = db.begin().await?;
674 let deleted = db::media_files::delete(&mut *tx, id, user.id).await?;
675 if let Some(ref row) = deleted {
676 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, row.file_size_bytes).await?;
677 db::pending_s3_deletions::enqueue_deletions(
678 &mut *tx,
679 &[(row.s3_key.clone(), "main".to_string())],
680 "media_delete",
681 )
682 .await?;
683 }
684 tx.commit().await?;
685 // The durable queue entry committed above is the source of truth; the
686 // queue worker performs the actual S3 delete (the only sanctioned path).
687
688 tracing::info!("Media file deleted: id={}, user={}", id, user.id);
689
690 Ok(Json(ConfirmUploadResponse {
691 success: true,
692 pending_review: None,
693 }))
694 }
695
696 #[cfg(test)]
697 mod tests {
698 use super::*;
699 use chrono::Utc;
700
701 fn make_media_file(folder: &str, filename: &str, s3_key: &str) -> db::DbMediaFile {
702 db::DbMediaFile {
703 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".parse().unwrap(),
704 user_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
705 folder: folder.to_string(),
706 filename: filename.to_string(),
707 s3_key: s3_key.to_string(),
708 content_type: "image/png".to_string(),
709 file_size_bytes: 1024,
710 media_type: "image".to_string(),
711 scan_status: "clean".to_string(),
712 created_at: Utc::now(),
713 }
714 }
715
716 #[test]
717 fn classify_media_image() {
718 let (media_type, file_type) = classify_media("image/png").unwrap();
719 assert_eq!(media_type, "image");
720 assert_eq!(file_type, FileType::MediaImage);
721 }
722
723 #[test]
724 fn classify_media_video() {
725 let (media_type, file_type) = classify_media("video/mp4").unwrap();
726 assert_eq!(media_type, "video");
727 assert_eq!(file_type, FileType::MediaVideo);
728 }
729
730 #[test]
731 fn classify_media_rejects_audio() {
732 assert!(classify_media("audio/mpeg").is_err());
733 }
734
735 #[test]
736 fn classify_media_rejects_text() {
737 assert!(classify_media("text/plain").is_err());
738 }
739
740 #[test]
741 fn classify_media_rejects_empty() {
742 assert!(classify_media("").is_err());
743 }
744
745 #[test]
746 fn file_to_response_root_folder() {
747 let f = make_media_file(
748 "",
749 "photo.png",
750 "11111111-1111-1111-1111-111111111111/media/photo.png",
751 );
752 let resp = file_to_response(&f, "https://cdn.example.com");
753 assert_eq!(
754 resp.cdn_url,
755 "https://cdn.example.com/11111111-1111-1111-1111-111111111111/media/photo.png"
756 );
757 assert_eq!(resp.markdown_ref, "![](photo.png)");
758 }
759
760 #[test]
761 fn file_to_response_with_folder() {
762 let f = make_media_file(
763 "screenshots",
764 "shot.png",
765 "11111111-1111-1111-1111-111111111111/media/screenshots/shot.png",
766 );
767 let resp = file_to_response(&f, "https://cdn.example.com");
768 assert_eq!(resp.markdown_ref, "![](screenshots/shot.png)");
769 }
770
771 #[test]
772 fn file_to_response_preserves_metadata() {
773 let f = make_media_file(
774 "docs",
775 "img.png",
776 "11111111-1111-1111-1111-111111111111/media/docs/img.png",
777 );
778 let resp = file_to_response(&f, "https://cdn.test");
779 assert_eq!(resp.folder, "docs");
780 assert_eq!(resp.filename, "img.png");
781 assert_eq!(resp.file_size_bytes, 1024);
782 assert_eq!(resp.media_type, "image");
783 }
784 }
785