Skip to main content

max / makenotwork

18.5 KB · 520 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 extract::{Path, Query, State},
10 response::IntoResponse,
11 Json,
12 };
13 use serde::{Deserialize, Serialize};
14
15 use crate::{
16 auth::AuthUser,
17 db::{self, MediaFileId},
18 error::{AppError, Result, ResultExt},
19 storage::{sanitize_folder, FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
20 AppState,
21 };
22
23 use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse};
24
25 // =============================================================================
26 // Request / Response Types
27 // =============================================================================
28
29 #[derive(Debug, Deserialize)]
30 pub struct MediaPresignRequest {
31 pub file_name: String,
32 pub content_type: String,
33 #[serde(default)]
34 pub folder: String,
35 }
36
37 #[derive(Debug, Deserialize)]
38 pub struct MediaConfirmRequest {
39 pub s3_key: String,
40 pub file_name: String,
41 pub content_type: String,
42 #[serde(default)]
43 pub folder: String,
44 }
45
46 #[derive(Debug, Deserialize)]
47 pub struct MediaListQuery {
48 pub folder: Option<String>,
49 }
50
51 #[derive(Debug, Serialize)]
52 pub struct MediaFileResponse {
53 pub id: MediaFileId,
54 pub folder: String,
55 pub filename: String,
56 pub content_type: String,
57 pub file_size_bytes: i64,
58 pub media_type: String,
59 pub cdn_url: String,
60 pub markdown_ref: String,
61 pub created_at: String,
62 }
63
64 #[derive(Debug, Serialize)]
65 pub struct MediaListResponse {
66 pub files: Vec<MediaFileResponse>,
67 pub folders: Vec<String>,
68 }
69
70 #[derive(Debug, Serialize)]
71 pub struct MediaFoldersResponse {
72 pub folders: Vec<String>,
73 }
74
75 // =============================================================================
76 // Helpers
77 // =============================================================================
78
79 /// Determine the media file type (image or video) from content type.
80 fn classify_media(content_type: &str) -> Result<(&'static str, FileType)> {
81 if content_type.starts_with("image/") {
82 Ok(("image", FileType::MediaImage))
83 } else if content_type.starts_with("video/") {
84 Ok(("video", FileType::MediaVideo))
85 } else {
86 Err(AppError::BadRequest(format!(
87 "Unsupported content type: {}. Only images and videos are allowed.",
88 content_type
89 )))
90 }
91 }
92
93 fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse {
94 let cdn_url = format!("{}/{}", cdn_base, f.s3_key);
95 let markdown_ref = if f.folder.is_empty() {
96 format!("![]({})", f.filename)
97 } else {
98 format!("![]({})", f.s3_key.trim_start_matches(&format!("{}/media/", f.user_id)))
99 };
100 MediaFileResponse {
101 id: f.id,
102 folder: f.folder.clone(),
103 filename: f.filename.clone(),
104 content_type: f.content_type.clone(),
105 file_size_bytes: f.file_size_bytes,
106 media_type: f.media_type.clone(),
107 cdn_url,
108 markdown_ref,
109 created_at: f.created_at.to_rfc3339(),
110 }
111 }
112
113 // =============================================================================
114 // Handlers
115 // =============================================================================
116
117 /// Generate a presigned URL for uploading a media file.
118 ///
119 /// POST /api/media/presign
120 #[tracing::instrument(skip_all, name = "media::presign", fields(user_id = %user.id))]
121 pub(super) async fn media_presign(
122 State(state): State<AppState>,
123 AuthUser(user): AuthUser,
124 Json(req): Json<MediaPresignRequest>,
125 ) -> Result<impl IntoResponse> {
126 user.check_not_suspended()?;
127 let s3 = state.require_s3()?;
128
129 let (media_type, file_type) = classify_media(&req.content_type)?;
130 let _ = media_type; // used at confirm time
131
132 // Validate content type and extension
133 S3Client::validate_content_type(file_type, &req.content_type)?;
134 S3Client::validate_extension(file_type, &req.file_name)?;
135
136 // Sanitize folder
137 let folder = sanitize_folder(&req.folder);
138
139 // Check for path traversal in folder
140 if req.folder.contains("..") {
141 return Err(AppError::BadRequest("Invalid folder name".to_string()));
142 }
143
144 // Early quota check — images bypass tier, video requires BigFiles+
145 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
146
147 // Filename uniqueness is enforced at confirm time by the
148 // `idx_media_files_user_folder_name` unique index — see `media_confirm`,
149 // which catches the duplicate-INSERT error, rolls back the storage
150 // credit, deletes the orphaned S3 object, and returns the clean
151 // "already exists" message. The pre-check we used to do here at presign
152 // time was racy (two concurrent presigns both pass the SELECT, then
153 // both try to upload and one wastes bandwidth) and the confirm-time
154 // path is authoritative either way.
155
156 // Generate S3 key
157 let s3_key = S3Client::generate_media_key(user.id, &folder, &req.file_name);
158
159 // Track the pending upload so the reaper can clean it up if never confirmed
160 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
161
162 let expires_in = 3600;
163 let upload_url = s3
164 .presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None)
165 .await
166 .context("presign upload for media file")?;
167
168 Ok(Json(PresignUploadResponse {
169 upload_url,
170 s3_key,
171 expires_in,
172 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
173 max_file_bytes: None,
174 }))
175 }
176
177 /// Confirm a completed media file upload.
178 ///
179 /// POST /api/media/confirm
180 #[tracing::instrument(skip_all, name = "media::confirm", fields(user_id = %user.id))]
181 pub(super) async fn media_confirm(
182 State(state): State<AppState>,
183 AuthUser(user): AuthUser,
184 Json(req): Json<MediaConfirmRequest>,
185 ) -> Result<impl IntoResponse> {
186 user.check_not_suspended()?;
187 let s3 = state.require_s3()?;
188
189 let (media_type, file_type) = classify_media(&req.content_type)?;
190
191 // Re-validate content type and extension at confirm time (may differ from presign)
192 S3Client::validate_content_type(file_type, &req.content_type)?;
193 S3Client::validate_extension(file_type, &req.file_name)?;
194
195 // Validate S3 key belongs to this user (prevent cross-user file reference)
196 let expected_prefix = format!("{}/media/", user.id);
197 if !req.s3_key.starts_with(&expected_prefix) {
198 return Err(AppError::BadRequest(
199 "Invalid upload key".to_string(),
200 ));
201 }
202
203 // Verify the object exists in S3
204 if !s3.object_exists(&req.s3_key).await? {
205 return Err(AppError::BadRequest(
206 "Upload not found. Please try uploading again.".to_string(),
207 ));
208 }
209
210 // Get file size
211 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
212 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
213 })?;
214 if file_size_bytes as u64 > file_type.max_size() {
215 s3.delete_object(&req.s3_key).await.ok();
216 return Err(AppError::BadRequest(format!(
217 "File exceeds maximum size of {} MB",
218 file_type.max_size() / (1024 * 1024)
219 )));
220 }
221
222 // Tier enforcement
223 let max_storage = match db::creator_tiers::check_upload_allowed(
224 &state.db, user.id, file_type, file_size_bytes,
225 )
226 .await
227 {
228 Ok(max) => max,
229 Err(e) => {
230 s3.delete_object(&req.s3_key).await.ok();
231 return Err(e);
232 }
233 };
234
235 let folder = sanitize_folder(&req.folder);
236 let safe_filename = req.file_name
237 .chars()
238 .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
239 .collect::<String>();
240
241 // Wrap storage credit + pending_uploads clear + media_files INSERT in a
242 // single transaction. The Run #5 audit flagged the previous non-atomic
243 // three-write sequence: a process interruption between writes could leave
244 // a charged storage counter with no row to refund against (storage credit
245 // leak), or a removed pending_uploads row with no media_files row + no
246 // tracker for the reaper (orphan S3 object + over-charge). With the tx,
247 // any rollback restores all three table states; only the S3 object needs
248 // explicit cleanup on failure.
249 //
250 // The unique index on (user_id, folder, filename) raises 23505 inside the
251 // tx; we catch the typed error after rollback and report a clean message.
252 let tx_result: Result<db::DbMediaFile> = async {
253 let mut tx = state.db.begin().await?;
254 db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage).await?;
255 db::pending_uploads::remove_pending_upload(&mut *tx, user.id, &req.s3_key).await?;
256 let row = db::media_files::create(
257 &mut *tx,
258 user.id,
259 &folder,
260 &safe_filename,
261 &req.s3_key,
262 &req.content_type,
263 file_size_bytes,
264 media_type,
265 db::FileScanStatus::Pending.to_string().as_str(),
266 )
267 .await?;
268 tx.commit().await?;
269 Ok(row)
270 }
271 .await;
272
273 let inserted = match tx_result {
274 Ok(row) => row,
275 Err(e) => {
276 tracing::warn!(error = ?e, "media_confirm transaction failed");
277 // Detect the duplicate case via the structured Postgres SQLSTATE
278 // (23505). The previous `e.to_string()` substring check broke when
279 // the AppError wrapper changed how the inner sqlx error rendered.
280 if let AppError::Database(sqlx::Error::Database(db_err)) = &e
281 && db_err.code().as_deref() == Some("23505")
282 {
283 // A 23505 here means this key is ALREADY live: media keys are
284 // deterministic by (user, folder, filename), so a duplicate
285 // filename and a concurrent/retried confirm both resolve to the
286 // same key, which is referenced by the committed row. The tx
287 // already rolled back the storage charge, so we must NOT delete
288 // the object — doing so would torpedo the existing file the live
289 // row points at (Run #11 HIGH). Reject the duplicate without
290 // touching S3.
291 return Err(AppError::BadRequest(format!(
292 "A file named '{}' already exists in folder '{}'.",
293 safe_filename,
294 if folder.is_empty() { "(root)" } else { &folder }
295 )));
296 }
297 // Any other failure: the tx rolled back and no row references this
298 // freshly-uploaded object, so it's a genuine orphan — clean it up.
299 s3.delete_object(&req.s3_key).await.ok();
300 return Err(e);
301 }
302 };
303
304 // Scan enqueue + scan_status flip AFTER the INSERT commits via the shared
305 // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner
306 // dev/test environment doesn't leave the row Pending forever.
307 let scan_status = commit_upload(
308 &state,
309 CommitTarget::Media(inserted.id),
310 &req.s3_key,
311 file_type,
312 user.id,
313 file_size_bytes,
314 ).await?;
315
316 tracing::info!(
317 "Media upload confirmed: user={}, folder={}, file={}, size={}",
318 user.id, folder, safe_filename, file_size_bytes
319 );
320
321 let pending_review = if scan_status == db::FileScanStatus::HeldForReview {
322 Some(true)
323 } else {
324 None
325 };
326 Ok(Json(ConfirmUploadResponse { success: true, pending_review }))
327 }
328
329 /// List media files for the authenticated user.
330 ///
331 /// GET /api/media?folder={folder}
332 #[tracing::instrument(skip_all, name = "media::list", fields(user_id = %user.id))]
333 pub(super) async fn media_list(
334 State(state): State<AppState>,
335 AuthUser(user): AuthUser,
336 Query(query): Query<MediaListQuery>,
337 ) -> Result<impl IntoResponse> {
338 // CDN base falls back to the production host so dev/test environments
339 // without CDN config still render plausible URLs. In production a
340 // missing `cdn_base_url` is an operator-side misconfiguration; we log
341 // a WARN once per process so it surfaces without blocking the request.
342 let cdn_base = if let Some(base) = state.config.cdn_base_url.as_deref() {
343 base
344 } else {
345 static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
346 WARNED.get_or_init(|| {
347 tracing::warn!(
348 "cdn_base_url not configured; falling back to https://cdn.makenot.work for media URLs"
349 );
350 });
351 "https://cdn.makenot.work"
352 };
353
354 let files = db::media_files::list_by_user_folder(
355 &state.db,
356 user.id,
357 query.folder.as_deref(),
358 )
359 .await?;
360
361 let folders = db::media_files::list_folders(&state.db, user.id).await?;
362
363 let file_responses: Vec<MediaFileResponse> = files
364 .iter()
365 .map(|f| file_to_response(f, cdn_base))
366 .collect();
367
368 Ok(Json(MediaListResponse {
369 files: file_responses,
370 folders,
371 }))
372 }
373
374 /// List distinct folder names for the authenticated user.
375 ///
376 /// GET /api/media/folders
377 #[tracing::instrument(skip_all, name = "media::folders", fields(user_id = %user.id))]
378 pub(super) async fn media_folders(
379 State(state): State<AppState>,
380 AuthUser(user): AuthUser,
381 ) -> Result<impl IntoResponse> {
382 let folders = db::media_files::list_folders(&state.db, user.id).await?;
383 Ok(Json(MediaFoldersResponse { folders }))
384 }
385
386 /// Delete a media file.
387 ///
388 /// DELETE /api/media/{id}
389 #[tracing::instrument(skip_all, name = "media::delete", fields(user_id = %user.id, media_id = %id))]
390 pub(super) async fn media_delete(
391 State(state): State<AppState>,
392 AuthUser(user): AuthUser,
393 Path(id): Path<MediaFileId>,
394 ) -> Result<impl IntoResponse> {
395 user.check_not_suspended()?;
396 let s3 = state.require_s3()?;
397
398 let file = db::media_files::get_by_id(&state.db, id)
399 .await?
400 .ok_or(AppError::NotFound)?;
401
402 // Verify ownership
403 if file.user_id != user.id {
404 return Err(AppError::Forbidden);
405 }
406
407 // Commit the DB delete + storage refund together. Doing the DB delete
408 // FIRST (and only refunding when it commits) avoids the previous race
409 // where a failed inline S3 delete still decremented the counter.
410 //
411 // Refund ONLY when the DELETE actually removed a row: `get_by_id` above is
412 // outside the tx, so a concurrent double-delete (double-click / retry) can
413 // let both requests past it; gating the decrement on `delete(...).is_some()`
414 // stops the second one from decrementing storage a second time and
415 // under-counting `storage_used_bytes` in the creator's favor (Run #12 LOW —
416 // the delete-side mirror of the confirm handlers' rows-affected discipline).
417 let mut tx = state.db.begin().await?;
418 let deleted = db::media_files::delete(&mut *tx, id).await?;
419 if deleted.is_some() {
420 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, file.file_size_bytes).await?;
421 }
422 tx.commit().await?;
423
424 // Enqueue S3 deletion only AFTER the DB commit succeeds. The Run #6 audit
425 // caught the previous ordering: if `tx.commit()` failed, the queue would
426 // delete the S3 object out from under the still-live DB row.
427 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
428 &state.db,
429 &[(file.s3_key.clone(), "main".to_string())],
430 "media_delete",
431 ).await {
432 tracing::warn!(error = ?e, "failed to enqueue S3 deletion for media file");
433 }
434
435 // Best-effort inline S3 delete. If this fails, the enqueued pending
436 // deletion above is the source of truth — the worker will retry.
437 if let Err(e) = s3.delete_object(&file.s3_key).await {
438 tracing::warn!(s3_key = %file.s3_key, error = ?e, "S3 delete failed for media file; pending_s3_deletions worker will retry");
439 }
440
441 tracing::info!("Media file deleted: id={}, user={}", id, user.id);
442
443 Ok(Json(ConfirmUploadResponse { success: true, pending_review: None }))
444 }
445
446 #[cfg(test)]
447 mod tests {
448 use super::*;
449 use chrono::Utc;
450
451 fn make_media_file(folder: &str, filename: &str, s3_key: &str) -> db::DbMediaFile {
452 db::DbMediaFile {
453 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".parse().unwrap(),
454 user_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
455 folder: folder.to_string(),
456 filename: filename.to_string(),
457 s3_key: s3_key.to_string(),
458 content_type: "image/png".to_string(),
459 file_size_bytes: 1024,
460 media_type: "image".to_string(),
461 scan_status: "clean".to_string(),
462 created_at: Utc::now(),
463 }
464 }
465
466 #[test]
467 fn classify_media_image() {
468 let (media_type, file_type) = classify_media("image/png").unwrap();
469 assert_eq!(media_type, "image");
470 assert_eq!(file_type, FileType::MediaImage);
471 }
472
473 #[test]
474 fn classify_media_video() {
475 let (media_type, file_type) = classify_media("video/mp4").unwrap();
476 assert_eq!(media_type, "video");
477 assert_eq!(file_type, FileType::MediaVideo);
478 }
479
480 #[test]
481 fn classify_media_rejects_audio() {
482 assert!(classify_media("audio/mpeg").is_err());
483 }
484
485 #[test]
486 fn classify_media_rejects_text() {
487 assert!(classify_media("text/plain").is_err());
488 }
489
490 #[test]
491 fn classify_media_rejects_empty() {
492 assert!(classify_media("").is_err());
493 }
494
495 #[test]
496 fn file_to_response_root_folder() {
497 let f = make_media_file("", "photo.png", "11111111-1111-1111-1111-111111111111/media/photo.png");
498 let resp = file_to_response(&f, "https://cdn.example.com");
499 assert_eq!(resp.cdn_url, "https://cdn.example.com/11111111-1111-1111-1111-111111111111/media/photo.png");
500 assert_eq!(resp.markdown_ref, "![](photo.png)");
501 }
502
503 #[test]
504 fn file_to_response_with_folder() {
505 let f = make_media_file("screenshots", "shot.png", "11111111-1111-1111-1111-111111111111/media/screenshots/shot.png");
506 let resp = file_to_response(&f, "https://cdn.example.com");
507 assert_eq!(resp.markdown_ref, "![](screenshots/shot.png)");
508 }
509
510 #[test]
511 fn file_to_response_preserves_metadata() {
512 let f = make_media_file("docs", "img.png", "11111111-1111-1111-1111-111111111111/media/docs/img.png");
513 let resp = file_to_response(&f, "https://cdn.test");
514 assert_eq!(resp.folder, "docs");
515 assert_eq!(resp.filename, "img.png");
516 assert_eq!(resp.file_size_bytes, 1024);
517 assert_eq!(resp.media_type, "image");
518 }
519 }
520