Skip to main content

max / makenotwork

23.8 KB · 636 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). Files are
5 //! stored in S3 under `{user_id}/media/{folder}/{filename}` and served
6 //! via `cdn.makenot.work`.
7
8 use axum::{
9 Json,
10 extract::{Path, Query, State},
11 response::IntoResponse,
12 };
13 use serde::{Deserialize, Serialize};
14 use sqlx::PgPool;
15
16 use crate::{
17 AppStorage, Scanning,
18 auth::AuthUser,
19 config::Config,
20 db::{self, MediaFileId},
21 error::{AppError, Result, ResultExt},
22 storage::{CACHE_CONTROL_IMMUTABLE, FileType, S3Client, sanitize_filename, sanitize_folder},
23 };
24
25 use super::{CommitTarget, ConfirmUploadResponse, PresignUploadResponse, commit_upload};
26
27 // Request / Response Types
28
29 #[derive(Debug, Deserialize)]
30 pub(crate) struct MediaPresignRequest {
31 pub file_name: String,
32 pub content_type: String,
33 #[serde(default)]
34 pub folder: String,
35 /// Optional declared size; when present it is signed into the presigned
36 /// URL's `Content-Length` so S3 rejects oversized PUTs at the protocol
37 /// level (media video is the largest upload class, up to 20 GB).
38 #[serde(default)]
39 pub file_size_bytes: Option<i64>,
40 }
41
42 #[derive(Debug, Deserialize)]
43 pub(crate) struct MediaConfirmRequest {
44 pub s3_key: String,
45 pub file_name: String,
46 pub content_type: String,
47 // The logical library name (folder + filename) is carried on the confirm.
48 // Under scan-then-promote the physical key is a content hash that no longer
49 // encodes the name, so there is nothing in the key for a client-supplied
50 // folder to disagree with (the Run #22 mismatch concern is gone), the
51 // `(user_id, folder, filename)` unique index guards the logical namespace,
52 // decoupled from the content-addressed object. Both are re-sanitized at
53 // confirm. Defaulted so a client omitting it lands the file in the root.
54 #[serde(default)]
55 pub folder: String,
56 }
57
58 #[derive(Debug, Deserialize)]
59 pub(crate) struct MediaListQuery {
60 pub folder: Option<String>,
61 }
62
63 #[derive(Debug, Serialize)]
64 pub(crate) struct MediaFileResponse {
65 pub id: MediaFileId,
66 pub folder: String,
67 pub filename: String,
68 pub content_type: String,
69 pub file_size_bytes: i64,
70 pub media_type: String,
71 pub cdn_url: String,
72 pub markdown_ref: String,
73 pub created_at: String,
74 }
75
76 #[derive(Debug, Serialize)]
77 pub(crate) struct MediaListResponse {
78 pub files: Vec<MediaFileResponse>,
79 pub folders: Vec<String>,
80 }
81
82 #[derive(Debug, Serialize)]
83 pub(crate) struct MediaFoldersResponse {
84 pub folders: Vec<String>,
85 }
86
87 // Helpers
88
89 /// Determine the media file type (image or video) from content type.
90 fn classify_media(content_type: &str) -> Result<(&'static str, FileType)> {
91 if content_type.starts_with("image/") {
92 Ok(("image", FileType::MediaImage))
93 } else if content_type.starts_with("video/") {
94 Ok(("video", FileType::MediaVideo))
95 } else {
96 Err(AppError::BadRequest(format!(
97 "Unsupported content type: {content_type}. Only images and videos are allowed."
98 )))
99 }
100 }
101
102 fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse {
103 let cdn_url = format!("{}/{}", cdn_base, f.s3_key);
104 // Folder-relative embed path, built from the logical name on the row, not
105 // the physical key, which under scan-then-promote is a content hash
106 // (`{user}/c/{sha}.ext`) that no longer encodes folder/filename.
107 let markdown_ref = if f.folder.is_empty() {
108 format!("![]({})", f.filename)
109 } else {
110 format!("![]({}/{})", f.folder, f.filename)
111 };
112 MediaFileResponse {
113 id: f.id,
114 folder: f.folder.clone(),
115 filename: f.filename.clone(),
116 content_type: f.content_type.clone(),
117 file_size_bytes: f.file_size_bytes,
118 media_type: f.media_type.clone(),
119 cdn_url,
120 markdown_ref,
121 created_at: f.created_at.to_rfc3339(),
122 }
123 }
124
125 // Handlers
126
127 /// Generate a presigned URL for uploading a media file.
128 ///
129 /// POST /api/media/presign
130 #[tracing::instrument(skip_all, name = "media::presign", fields(user_id = %user.id))]
131 pub(super) async fn media_presign(
132 State(db): State<PgPool>,
133 State(storage): State<AppStorage>,
134 AuthUser(user): AuthUser,
135 Json(req): Json<MediaPresignRequest>,
136 ) -> Result<impl IntoResponse> {
137 user.check_not_suspended()?;
138 let s3 = storage.require_s3()?;
139
140 let (media_type, file_type) = classify_media(&req.content_type)?;
141 let _ = media_type; // used at confirm time
142
143 // Validate content type and extension
144 S3Client::validate_content_type(file_type, &req.content_type)?;
145 S3Client::validate_extension(file_type, &req.file_name)?;
146
147 let folder = sanitize_folder(&req.folder);
148
149 // Check for path traversal in folder
150 if req.folder.contains("..") {
151 return Err(AppError::BadRequest("Invalid folder name".to_string()));
152 }
153
154 // Early quota check, images bypass tier, video requires BigFiles+
155 db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?;
156
157 // Validate the declared size against the static per-type cap AND the user's
158 // tier per-file cap before signing it into Content-Length, so an oversize
159 // media upload is rejected at presign instead of after the bytes are spent
160 // and only caught at confirm. `get_effective_max_file_bytes` returns None for
161 // images (they bypass the tier cap) and the tier limit for video.
162 let max_file_bytes =
163 db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?;
164 super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?;
165
166 // Filename uniqueness is enforced at confirm time by the
167 // `idx_media_files_user_folder_name` unique index, see `media_confirm`,
168 // which catches the duplicate-INSERT error, rolls back the storage
169 // credit, deletes the orphaned S3 object, and returns the clean
170 // "already exists" message. The pre-check we used to do here at presign
171 // time was racy (two concurrent presigns both pass the SELECT, then
172 // both try to upload and one wastes bandwidth) and the confirm-time
173 // path is authoritative either way.
174
175 // Staging key (unserved); the scan worker promotes it to the content key on a
176 // Clean verdict (C1). The logical library name (folder + filename) is no
177 // longer encoded in the physical key, it is carried on the row and re-derived
178 // from the (sanitized) request at confirm.
179 let _ = &folder; // validated above for early rejection; not woven into the key
180 let s3_key = S3Client::generate_staging_key(&req.file_name);
181
182 // Track the pending upload so the reaper can clean it up if never confirmed
183 db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?;
184
185 let expires_in = 3600;
186 let upload_url = s3
187 .presign_upload(
188 &s3_key,
189 &req.content_type,
190 Some(expires_in),
191 Some(CACHE_CONTROL_IMMUTABLE),
192 req.file_size_bytes,
193 )
194 .await
195 .context("presign upload for media file")?;
196
197 Ok(Json(PresignUploadResponse {
198 upload_url,
199 s3_key: s3_key.into_string(),
200 expires_in,
201 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
202 max_file_bytes: None,
203 }))
204 }
205
206 /// Confirm a completed media file upload.
207 ///
208 /// POST /api/media/confirm
209 #[tracing::instrument(skip_all, name = "media::confirm", fields(user_id = %user.id))]
210 pub(super) async fn media_confirm(
211 State(db): State<PgPool>,
212 State(storage): State<AppStorage>,
213 State(scanning): State<Scanning>,
214 AuthUser(user): AuthUser,
215 Json(req): Json<MediaConfirmRequest>,
216 ) -> Result<impl IntoResponse> {
217 user.check_not_suspended()?;
218 let s3 = storage.require_s3()?;
219
220 let (media_type, file_type) = classify_media(&req.content_type)?;
221
222 // Re-validate content type and extension at confirm time (may differ from presign)
223 S3Client::validate_content_type(file_type, &req.content_type)?;
224 S3Client::validate_extension(file_type, &req.file_name)?;
225
226 // Authorize the staging key: a `staging/{uuid}` key has no user in its path,
227 // so ownership is proved via the `pending_uploads` row recorded at presign,
228 // not a prefix check. Gate before the sniff/size-reject paths so an unowned
229 // (at most another user's in-flight) staging object is never enqueued for
230 // deletion.
231 if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? {
232 return Err(AppError::BadRequest("Invalid upload key".to_string()));
233 }
234
235 // Verify the object exists in S3
236 if !s3.object_exists(&req.s3_key).await? {
237 return Err(AppError::BadRequest(
238 "Upload not found. Please try uploading again.".to_string(),
239 ));
240 }
241
242 // Get file size
243 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
244 AppError::BadRequest(
245 "Could not determine file size. Please try uploading again.".to_string(),
246 )
247 })?;
248 if file_size_bytes as u64 > file_type.max_size() {
249 super::enqueue_s3_orphan(
250 &db,
251 &req.s3_key,
252 crate::storage::S3Bucket::Main,
253 "media_upload_rejected",
254 )
255 .await;
256 return Err(AppError::BadRequest(format!(
257 "File exceeds maximum size of {} MB",
258 file_type.max_size() / (1024 * 1024)
259 )));
260 }
261
262 // Reconcile the real media category against the declared content_type before
263 // tier enforcement. The declared type is client-controlled, it is bound into
264 // the presigned PUT and merely echoed back by S3 metadata, so trusting it
265 // lets a video be declared `image/png` and dodge the BigFiles+ video-tier
266 // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is
267 // detectably a different audio/visual category than declared, reject + orphan.
268 {
269 // Ranged read of just the header, a 4 KB sniff must not transfer the
270 // whole (up to 20 GB) object. Production issues `Range: bytes=0-4095`.
271 let head = s3.download_head(&req.s3_key, 4096).await?;
272 let detected = infer::get(&head).map(|kind| kind.matcher_type());
273 // `media_type` is only ever "image" or "video" (see `classify_media`).
274 let mismatch = match media_type {
275 // Every allowed image format (jpeg/png/webp/gif) is positively
276 // detected by `infer`, so a declared image MUST sniff as an image.
277 // Requiring a positive image signature, rather than only rejecting
278 // a *detected* video, closes the bypass where a video `infer`
279 // cannot name its container is declared `image/png` to dodge the
280 // BigFiles+ video-tier gate (Run #22 / Run #5 Storage): a video
281 // never sniffs as Image, so it is rejected here either way.
282 "image" => detected != Some(infer::MatcherType::Image),
283 // Declared video: do NOT require a positive video signature,
284 // `infer` cannot classify every valid container (fragmented mp4,
285 // some mov/webm), and there is no tier-evasion incentive to declare
286 // a video as a video. Only reject a still image mislabeled as video.
287 "video" => detected == Some(infer::MatcherType::Image),
288 _ => false,
289 };
290 if mismatch {
291 super::enqueue_s3_orphan(
292 &db,
293 &req.s3_key,
294 crate::storage::S3Bucket::Main,
295 "media_content_type_mismatch",
296 )
297 .await;
298 let sniffed = match detected {
299 Some(infer::MatcherType::Image) => "image",
300 Some(infer::MatcherType::Video) => "video",
301 _ => "an unrecognized format",
302 };
303 return Err(AppError::BadRequest(format!(
304 "Uploaded file does not match the declared {media_type} type (detected: {sniffed})."
305 )));
306 }
307 }
308
309 // Tier enforcement
310 let max_storage =
311 match db::creator_tiers::check_upload_allowed(&db, user.id, file_type, file_size_bytes)
312 .await
313 {
314 Ok(max) => max,
315 Err(e) => {
316 super::enqueue_s3_orphan(
317 &db,
318 &req.s3_key,
319 crate::storage::S3Bucket::Main,
320 "media_upload_rejected",
321 )
322 .await;
323 return Err(e);
324 }
325 };
326
327 // Derive the logical library name (folder + filename) from the request,
328 // re-applying the same sanitizers presign used. Under scan-then-promote the
329 // physical key is a content hash that no longer encodes the name (the Run #22
330 // key-vs-name mismatch it guarded against is gone, the name is now a purely
331 // logical namespace, enforced by the `(user_id, folder, filename)` unique
332 // index, decoupled from the content-addressed object).
333 if req.folder.contains("..") {
334 return Err(AppError::BadRequest("Invalid folder name".to_string()));
335 }
336 let folder = sanitize_folder(&req.folder);
337 let safe_filename = sanitize_filename(&req.file_name);
338 if safe_filename.is_empty() {
339 return Err(AppError::BadRequest("Invalid file name".to_string()));
340 }
341
342 // Wrap storage credit + pending_uploads clear + media_files INSERT in a
343 // single transaction. The Run #5 audit flagged the previous non-atomic
344 // three-write sequence: a process interruption between writes could leave
345 // a charged storage counter with no row to refund against (storage credit
346 // leak), or a removed pending_uploads row with no media_files row + no
347 // tracker for the reaper (orphan S3 object + over-charge). With the tx,
348 // any rollback restores all three table states; only the S3 object needs
349 // explicit cleanup on failure.
350 //
351 // The unique index on (user_id, folder, filename) raises 23505 inside the
352 // tx; we catch the typed error after rollback and report a clean message.
353 let tx_result: Result<db::DbMediaFile> = async {
354 let mut tx = db.begin().await?;
355 db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage)
356 .await?;
357 db::pending_uploads::remove_pending_upload(&mut *tx, user.id, &req.s3_key, "main").await?;
358 let row = db::media_files::create(
359 &mut *tx,
360 user.id,
361 &folder,
362 &safe_filename,
363 &req.s3_key,
364 &req.content_type,
365 file_size_bytes,
366 media_type,
367 db::FileScanStatus::Pending.to_string().as_str(),
368 )
369 .await?;
370 tx.commit().await?;
371 Ok(row)
372 }
373 .await;
374
375 let inserted = match tx_result {
376 Ok(row) => row,
377 Err(e) => {
378 tracing::warn!(error = ?e, "media_confirm transaction failed");
379 // Detect the duplicate case via the structured Postgres SQLSTATE
380 // (23505). The previous `e.to_string()` substring check broke when
381 // the AppError wrapper changed how the inner sqlx error rendered.
382 if let AppError::Database(sqlx::Error::Database(db_err)) = &e
383 && db_err.code().as_deref() == Some("23505")
384 {
385 // A 23505 here means this key is ALREADY live: media keys are
386 // deterministic by (user, folder, filename), so a duplicate
387 // filename and a concurrent/retried confirm both resolve to the
388 // same key, which is referenced by the committed row. The tx
389 // already rolled back the storage charge, so we must NOT delete
390 // the object, doing so would torpedo the existing file the live
391 // row points at (Run #11 HIGH). Reject the duplicate without
392 // touching S3.
393 return Err(AppError::BadRequest(format!(
394 "A file named '{}' already exists in folder '{}'.",
395 safe_filename,
396 if folder.is_empty() { "(root)" } else { &folder }
397 )));
398 }
399 // Any other failure: the tx rolled back and no row references this
400 // freshly-uploaded object, so it's a genuine orphan, clean it up.
401 super::enqueue_s3_orphan(
402 &db,
403 &req.s3_key,
404 crate::storage::S3Bucket::Main,
405 "media_upload_rejected",
406 )
407 .await;
408 return Err(e);
409 }
410 };
411
412 // Scan enqueue + scan_status flip AFTER the INSERT commits via the shared
413 // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner
414 // dev/test environment doesn't leave the row Pending forever.
415 let scan_status = commit_upload(
416 &db,
417 scanning.scanner.as_ref(),
418 CommitTarget::Media(inserted.id),
419 &req.s3_key,
420 file_type,
421 user.id,
422 file_size_bytes,
423 )
424 .await?;
425
426 tracing::info!(
427 "Media upload confirmed: user={}, folder={}, file={}, size={}",
428 user.id,
429 folder,
430 safe_filename,
431 file_size_bytes
432 );
433
434 let pending_review = if scan_status == db::FileScanStatus::HeldForReview {
435 Some(true)
436 } else {
437 None
438 };
439 Ok(Json(ConfirmUploadResponse {
440 success: true,
441 pending_review,
442 }))
443 }
444
445 /// List media files for the authenticated user.
446 ///
447 /// GET /api/media?folder={folder}
448 #[tracing::instrument(skip_all, name = "media::list", fields(user_id = %user.id))]
449 pub(super) async fn media_list(
450 State(db): State<PgPool>,
451 State(config): State<Config>,
452 AuthUser(user): AuthUser,
453 Query(query): Query<MediaListQuery>,
454 ) -> Result<impl IntoResponse> {
455 let cdn_base = config.cdn_base_url.as_str();
456
457 let files = db::media_files::list_by_user_folder(&db, user.id, query.folder.as_deref()).await?;
458
459 let folders = db::media_files::list_folders(&db, user.id).await?;
460
461 let file_responses: Vec<MediaFileResponse> = files
462 .iter()
463 .map(|f| file_to_response(f, cdn_base))
464 .collect();
465
466 Ok(Json(MediaListResponse {
467 files: file_responses,
468 folders,
469 }))
470 }
471
472 /// List distinct folder names for the authenticated user.
473 ///
474 /// GET /api/media/folders
475 #[tracing::instrument(skip_all, name = "media::folders", fields(user_id = %user.id))]
476 pub(super) async fn media_folders(
477 State(db): State<PgPool>,
478 AuthUser(user): AuthUser,
479 ) -> Result<impl IntoResponse> {
480 let folders = db::media_files::list_folders(&db, user.id).await?;
481 Ok(Json(MediaFoldersResponse { folders }))
482 }
483
484 /// Delete a media file.
485 ///
486 /// DELETE /api/media/{id}
487 #[tracing::instrument(skip_all, name = "media::delete", fields(user_id = %user.id, media_id = %id))]
488 pub(super) async fn media_delete(
489 State(db): State<PgPool>,
490 State(storage): State<AppStorage>,
491 AuthUser(user): AuthUser,
492 Path(id): Path<MediaFileId>,
493 ) -> Result<impl IntoResponse> {
494 user.check_not_suspended()?;
495 // Require S3 to be configured, but the actual delete goes through the
496 // durable queue (the sanctioned deletion path) rather than a direct call.
497 storage.require_s3()?;
498
499 let file = db::media_files::get_by_id(&db, id)
500 .await?
501 .ok_or(AppError::NotFound)?;
502
503 // Verify ownership
504 if file.user_id != user.id {
505 return Err(AppError::Forbidden);
506 }
507
508 // Commit the DB delete + storage refund together. Doing the DB delete
509 // FIRST (and only refunding when it commits) avoids the previous race
510 // where a failed inline S3 delete still decremented the counter.
511 //
512 // Refund ONLY when the DELETE actually removed a row: `get_by_id` above is
513 // outside the tx, so a concurrent double-delete (double-click / retry) can
514 // let both requests past it; gating the decrement on `delete(...).is_some()`
515 // stops the second one from decrementing storage a second time and
516 // under-counting `storage_used_bytes` in the creator's favor (Run #12 LOW,
517 // the delete-side mirror of the confirm handlers' rows-affected discipline).
518 // Row delete + storage refund + S3-deletion enqueue all in ONE transaction.
519 // Enqueueing inside the tx (rather than after commit) closes the crash window
520 // where a commit followed by a failed post-commit enqueue orphaned the object
521 // with no durable record (Run #18 Storage B6, the same in-tx ordering
522 // delete_version adopted). The refund + enqueue use the DELETE's own returned
523 // row (`deleted`), not the pre-tx `get_by_id` read.
524 let mut tx = db.begin().await?;
525 let deleted = db::media_files::delete(&mut *tx, id, user.id).await?;
526 if let Some(ref row) = deleted {
527 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, row.file_size_bytes).await?;
528 db::pending_s3_deletions::enqueue_deletions(
529 &mut *tx,
530 &[(row.s3_key.clone(), "main".to_string())],
531 "media_delete",
532 )
533 .await?;
534 }
535 tx.commit().await?;
536 // The durable queue entry committed above is the source of truth; the
537 // queue worker performs the actual S3 delete (the only sanctioned path).
538
539 tracing::info!("Media file deleted: id={}, user={}", id, user.id);
540
541 Ok(Json(ConfirmUploadResponse {
542 success: true,
543 pending_review: None,
544 }))
545 }
546
547 #[cfg(test)]
548 mod tests {
549 use super::*;
550 use chrono::Utc;
551
552 fn make_media_file(folder: &str, filename: &str, s3_key: &str) -> db::DbMediaFile {
553 db::DbMediaFile {
554 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".parse().unwrap(),
555 user_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
556 folder: folder.to_string(),
557 filename: filename.to_string(),
558 s3_key: s3_key.to_string(),
559 content_type: "image/png".to_string(),
560 file_size_bytes: 1024,
561 media_type: "image".to_string(),
562 scan_status: "clean".to_string(),
563 created_at: Utc::now(),
564 }
565 }
566
567 #[test]
568 fn classify_media_image() {
569 let (media_type, file_type) = classify_media("image/png").unwrap();
570 assert_eq!(media_type, "image");
571 assert_eq!(file_type, FileType::MediaImage);
572 }
573
574 #[test]
575 fn classify_media_video() {
576 let (media_type, file_type) = classify_media("video/mp4").unwrap();
577 assert_eq!(media_type, "video");
578 assert_eq!(file_type, FileType::MediaVideo);
579 }
580
581 #[test]
582 fn classify_media_rejects_audio() {
583 assert!(classify_media("audio/mpeg").is_err());
584 }
585
586 #[test]
587 fn classify_media_rejects_text() {
588 assert!(classify_media("text/plain").is_err());
589 }
590
591 #[test]
592 fn classify_media_rejects_empty() {
593 assert!(classify_media("").is_err());
594 }
595
596 #[test]
597 fn file_to_response_root_folder() {
598 let f = make_media_file(
599 "",
600 "photo.png",
601 "11111111-1111-1111-1111-111111111111/media/photo.png",
602 );
603 let resp = file_to_response(&f, "https://cdn.example.com");
604 assert_eq!(
605 resp.cdn_url,
606 "https://cdn.example.com/11111111-1111-1111-1111-111111111111/media/photo.png"
607 );
608 assert_eq!(resp.markdown_ref, "![](photo.png)");
609 }
610
611 #[test]
612 fn file_to_response_with_folder() {
613 let f = make_media_file(
614 "screenshots",
615 "shot.png",
616 "11111111-1111-1111-1111-111111111111/media/screenshots/shot.png",
617 );
618 let resp = file_to_response(&f, "https://cdn.example.com");
619 assert_eq!(resp.markdown_ref, "![](screenshots/shot.png)");
620 }
621
622 #[test]
623 fn file_to_response_preserves_metadata() {
624 let f = make_media_file(
625 "docs",
626 "img.png",
627 "11111111-1111-1111-1111-111111111111/media/docs/img.png",
628 );
629 let resp = file_to_response(&f, "https://cdn.test");
630 assert_eq!(resp.folder, "docs");
631 assert_eq!(resp.filename, "img.png");
632 assert_eq!(resp.file_size_bytes, 1024);
633 assert_eq!(resp.media_type, "image");
634 }
635 }
636