Skip to main content

max / makenotwork

17.2 KB · 502 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")]
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")]
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 s3.delete_object(&req.s3_key).await.ok();
277 tracing::warn!(error = ?e, "media_confirm transaction failed");
278 // Detect the duplicate-filename case via the structured Postgres
279 // SQLSTATE (23505). The previous `e.to_string()` substring check
280 // broke when the AppError wrapper changed how the inner sqlx
281 // error rendered.
282 if let AppError::Database(sqlx::Error::Database(db_err)) = &e
283 && db_err.code().as_deref() == Some("23505")
284 {
285 return Err(AppError::BadRequest(format!(
286 "A file named '{}' already exists in folder '{}'.",
287 safe_filename,
288 if folder.is_empty() { "(root)" } else { &folder }
289 )));
290 }
291 return Err(e);
292 }
293 };
294
295 // Scan enqueue + scan_status flip AFTER the INSERT commits via the shared
296 // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner
297 // dev/test environment doesn't leave the row Pending forever.
298 let scan_status = commit_upload(
299 &state,
300 CommitTarget::Media(inserted.id),
301 &req.s3_key,
302 file_type,
303 user.id,
304 file_size_bytes,
305 ).await?;
306
307 tracing::info!(
308 "Media upload confirmed: user={}, folder={}, file={}, size={}",
309 user.id, folder, safe_filename, file_size_bytes
310 );
311
312 let pending_review = if scan_status == db::FileScanStatus::HeldForReview {
313 Some(true)
314 } else {
315 None
316 };
317 Ok(Json(ConfirmUploadResponse { success: true, pending_review }))
318 }
319
320 /// List media files for the authenticated user.
321 ///
322 /// GET /api/media?folder={folder}
323 #[tracing::instrument(skip_all, name = "media::list")]
324 pub(super) async fn media_list(
325 State(state): State<AppState>,
326 AuthUser(user): AuthUser,
327 Query(query): Query<MediaListQuery>,
328 ) -> Result<impl IntoResponse> {
329 // CDN base falls back to the production host so dev/test environments
330 // without CDN config still render plausible URLs. In production a
331 // missing `cdn_base_url` is an operator-side misconfiguration; we log
332 // a WARN once per process so it surfaces without blocking the request.
333 let cdn_base = if let Some(base) = state.config.cdn_base_url.as_deref() {
334 base
335 } else {
336 static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
337 WARNED.get_or_init(|| {
338 tracing::warn!(
339 "cdn_base_url not configured; falling back to https://cdn.makenot.work for media URLs"
340 );
341 });
342 "https://cdn.makenot.work"
343 };
344
345 let files = db::media_files::list_by_user_folder(
346 &state.db,
347 user.id,
348 query.folder.as_deref(),
349 )
350 .await?;
351
352 let folders = db::media_files::list_folders(&state.db, user.id).await?;
353
354 let file_responses: Vec<MediaFileResponse> = files
355 .iter()
356 .map(|f| file_to_response(f, cdn_base))
357 .collect();
358
359 Ok(Json(MediaListResponse {
360 files: file_responses,
361 folders,
362 }))
363 }
364
365 /// List distinct folder names for the authenticated user.
366 ///
367 /// GET /api/media/folders
368 #[tracing::instrument(skip_all, name = "media::folders")]
369 pub(super) async fn media_folders(
370 State(state): State<AppState>,
371 AuthUser(user): AuthUser,
372 ) -> Result<impl IntoResponse> {
373 let folders = db::media_files::list_folders(&state.db, user.id).await?;
374 Ok(Json(MediaFoldersResponse { folders }))
375 }
376
377 /// Delete a media file.
378 ///
379 /// DELETE /api/media/{id}
380 #[tracing::instrument(skip_all, name = "media::delete")]
381 pub(super) async fn media_delete(
382 State(state): State<AppState>,
383 AuthUser(user): AuthUser,
384 Path(id): Path<MediaFileId>,
385 ) -> Result<impl IntoResponse> {
386 user.check_not_suspended()?;
387 let s3 = state.require_s3()?;
388
389 let file = db::media_files::get_by_id(&state.db, id)
390 .await?
391 .ok_or(AppError::NotFound)?;
392
393 // Verify ownership
394 if file.user_id != user.id {
395 return Err(AppError::Forbidden);
396 }
397
398 // Commit the DB delete + storage refund together. Doing the DB delete
399 // FIRST (and only refunding when it commits) avoids the previous race
400 // where a failed inline S3 delete still decremented the counter.
401 let mut tx = state.db.begin().await?;
402 db::media_files::delete(&mut *tx, id).await?;
403 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, file.file_size_bytes).await?;
404 tx.commit().await?;
405
406 // Enqueue S3 deletion only AFTER the DB commit succeeds. The Run #6 audit
407 // caught the previous ordering: if `tx.commit()` failed, the queue would
408 // delete the S3 object out from under the still-live DB row.
409 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
410 &state.db,
411 &[(file.s3_key.clone(), "main".to_string())],
412 "media_delete",
413 ).await {
414 tracing::warn!(error = ?e, "failed to enqueue S3 deletion for media file");
415 }
416
417 // Best-effort inline S3 delete. If this fails, the enqueued pending
418 // deletion above is the source of truth — the worker will retry.
419 if let Err(e) = s3.delete_object(&file.s3_key).await {
420 tracing::warn!(s3_key = %file.s3_key, error = ?e, "S3 delete failed for media file; pending_s3_deletions worker will retry");
421 }
422
423 tracing::info!("Media file deleted: id={}, user={}", id, user.id);
424
425 Ok(Json(ConfirmUploadResponse { success: true, pending_review: None }))
426 }
427
428 #[cfg(test)]
429 mod tests {
430 use super::*;
431 use chrono::Utc;
432
433 fn make_media_file(folder: &str, filename: &str, s3_key: &str) -> db::DbMediaFile {
434 db::DbMediaFile {
435 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".parse().unwrap(),
436 user_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
437 folder: folder.to_string(),
438 filename: filename.to_string(),
439 s3_key: s3_key.to_string(),
440 content_type: "image/png".to_string(),
441 file_size_bytes: 1024,
442 media_type: "image".to_string(),
443 scan_status: "clean".to_string(),
444 created_at: Utc::now(),
445 }
446 }
447
448 #[test]
449 fn classify_media_image() {
450 let (media_type, file_type) = classify_media("image/png").unwrap();
451 assert_eq!(media_type, "image");
452 assert_eq!(file_type, FileType::MediaImage);
453 }
454
455 #[test]
456 fn classify_media_video() {
457 let (media_type, file_type) = classify_media("video/mp4").unwrap();
458 assert_eq!(media_type, "video");
459 assert_eq!(file_type, FileType::MediaVideo);
460 }
461
462 #[test]
463 fn classify_media_rejects_audio() {
464 assert!(classify_media("audio/mpeg").is_err());
465 }
466
467 #[test]
468 fn classify_media_rejects_text() {
469 assert!(classify_media("text/plain").is_err());
470 }
471
472 #[test]
473 fn classify_media_rejects_empty() {
474 assert!(classify_media("").is_err());
475 }
476
477 #[test]
478 fn file_to_response_root_folder() {
479 let f = make_media_file("", "photo.png", "11111111-1111-1111-1111-111111111111/media/photo.png");
480 let resp = file_to_response(&f, "https://cdn.example.com");
481 assert_eq!(resp.cdn_url, "https://cdn.example.com/11111111-1111-1111-1111-111111111111/media/photo.png");
482 assert_eq!(resp.markdown_ref, "![](photo.png)");
483 }
484
485 #[test]
486 fn file_to_response_with_folder() {
487 let f = make_media_file("screenshots", "shot.png", "11111111-1111-1111-1111-111111111111/media/screenshots/shot.png");
488 let resp = file_to_response(&f, "https://cdn.example.com");
489 assert_eq!(resp.markdown_ref, "![](screenshots/shot.png)");
490 }
491
492 #[test]
493 fn file_to_response_preserves_metadata() {
494 let f = make_media_file("docs", "img.png", "11111111-1111-1111-1111-111111111111/media/docs/img.png");
495 let resp = file_to_response(&f, "https://cdn.test");
496 assert_eq!(resp.folder, "docs");
497 assert_eq!(resp.filename, "img.png");
498 assert_eq!(resp.file_size_bytes, 1024);
499 assert_eq!(resp.media_type, "image");
500 }
501 }
502