Skip to main content

max / makenotwork

18.9 KB · 526 lines History Blame Raw
1 //! Presigned upload and confirm handlers for project and item images.
2
3 use axum::{
4 extract::State,
5 response::IntoResponse,
6 Json,
7 };
8 use serde::{Deserialize, Serialize};
9
10 use crate::{
11 auth::AuthUser,
12 db::{self, ItemId, ProjectId},
13 error::{AppError, Result, ResultExt},
14 storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
15 AppState,
16 };
17
18 use super::{commit_upload, CommitTarget, PresignUploadResponse};
19
20 /// JSON input for requesting a presigned project image upload URL.
21 #[derive(Debug, Deserialize)]
22 pub struct ProjectImagePresignRequest {
23 pub project_id: ProjectId,
24 pub file_name: String,
25 pub content_type: String,
26 }
27
28 /// JSON input for confirming a completed project image upload.
29 #[derive(Debug, Deserialize)]
30 pub struct ProjectImageConfirmRequest {
31 pub project_id: ProjectId,
32 pub s3_key: String,
33 }
34
35 /// JSON response from a successful project image confirm.
36 #[derive(Debug, Serialize)]
37 pub struct ProjectImageConfirmResponse {
38 pub success: bool,
39 pub image_url: String,
40 }
41
42 /// JSON input for requesting a presigned item image upload URL.
43 #[derive(Debug, Deserialize)]
44 pub struct ItemImagePresignRequest {
45 pub item_id: ItemId,
46 pub file_name: String,
47 pub content_type: String,
48 }
49
50 /// JSON input for confirming a completed item image upload.
51 #[derive(Debug, Deserialize)]
52 pub struct ItemImageConfirmRequest {
53 pub item_id: ItemId,
54 pub s3_key: String,
55 }
56
57 /// Generate a presigned URL for uploading a project image
58 ///
59 /// POST /api/projects/image/presign
60 ///
61 /// Requires authentication. User must own the project.
62 #[tracing::instrument(skip_all, name = "storage::project_image_presign", fields(user_id = %user.id))]
63 pub(super) async fn project_image_presign(
64 State(state): State<AppState>,
65 AuthUser(user): AuthUser,
66 Json(req): Json<ProjectImagePresignRequest>,
67 ) -> Result<impl IntoResponse> {
68 user.check_not_suspended()?;
69 let s3 = state.require_s3()?;
70
71 let file_type = FileType::Cover;
72 S3Client::validate_content_type(file_type, &req.content_type)?;
73 S3Client::validate_extension(file_type, &req.file_name)?;
74
75 // Verify user owns the project
76 let project = db::projects::get_project_by_id(&state.db, req.project_id)
77 .await?
78 .ok_or(AppError::NotFound)?;
79
80 if project.user_id != user.id {
81 return Err(AppError::Forbidden);
82 }
83
84 // Early quota check
85 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
86
87 let s3_key = S3Client::generate_project_image_key(req.project_id, &req.file_name);
88
89 // Track the pending upload so the reaper can clean it up if never confirmed
90 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
91
92 let expires_in = 3600;
93 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None)
94 .await
95 .context("presign upload for project image")?;
96
97 Ok(Json(PresignUploadResponse {
98 upload_url,
99 s3_key,
100 expires_in,
101 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
102 max_file_bytes: None,
103 }))
104 }
105
106 /// Confirm a project image upload, scan, store URL
107 ///
108 /// POST /api/projects/image/confirm
109 ///
110 /// Requires authentication. User must own the project.
111 #[tracing::instrument(skip_all, name = "storage::project_image_confirm", fields(user_id = %user.id))]
112 pub(super) async fn project_image_confirm(
113 State(state): State<AppState>,
114 AuthUser(user): AuthUser,
115 Json(req): Json<ProjectImageConfirmRequest>,
116 ) -> Result<impl IntoResponse> {
117 user.check_not_suspended()?;
118 let s3 = state.require_s3()?;
119
120 // Verify user owns the project
121 let project = db::projects::get_project_by_id(&state.db, req.project_id)
122 .await?
123 .ok_or(AppError::NotFound)?;
124
125 if project.user_id != user.id {
126 return Err(AppError::Forbidden);
127 }
128
129 // Validate S3 key belongs to this project (prevent cross-project file reference)
130 let expected_prefix = format!("projects/{}/image/", req.project_id);
131 if !req.s3_key.starts_with(&expected_prefix) {
132 return Err(AppError::BadRequest(
133 "Invalid upload key".to_string(),
134 ));
135 }
136
137 // Verify the object exists in S3
138 if !s3.object_exists(&req.s3_key).await? {
139 return Err(AppError::BadRequest(
140 "Upload not found. Please try uploading again.".to_string(),
141 ));
142 }
143
144 // Enforce file size limit
145 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
146 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
147 })?;
148 if file_size_bytes as u64 > FileType::Cover.max_size() {
149 s3.delete_object(&req.s3_key).await.ok();
150 return Err(AppError::BadRequest(format!(
151 "File exceeds maximum size of {} MB",
152 FileType::Cover.max_size() / (1024 * 1024)
153 )));
154 }
155
156 // Idempotency: if the project already references this same s3_key, return success.
157 // The Run #6 audit caught a silent-data-loss bug here: without this check, a benign
158 // retry would queue `req.s3_key` (== current `cover_image_url`) for deletion.
159 if let Some(ref cur_url) = project.cover_image_url
160 && let Some(cur_key) = storage::extract_s3_key_from_url(
161 cur_url,
162 state.config.cdn_base_url.as_deref(),
163 Some(s3.bucket()),
164 state.config.storage.as_ref().map(|c| c.endpoint.as_str()),
165 )
166 && cur_key == req.s3_key
167 {
168 // Still clear pending_uploads — orphan reaper would otherwise delete
169 // the live S3 object 24h later (Run #7 HIGH-1).
170 if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await {
171 tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm");
172 }
173 return Ok(Json(ProjectImageConfirmResponse {
174 success: true,
175 image_url: cur_url.clone(),
176 }));
177 }
178
179 // Enforce tier-based limits
180 let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await {
181 Ok(max) => max,
182 Err(e) => {
183 s3.delete_object(&req.s3_key).await.ok();
184 return Err(e);
185 }
186 };
187
188 // Probe the old S3 object's size FIRST (async, before any tx). If the row
189 // references an old image we MUST determine its size or the storage counter
190 // drifts on every replacement; treating Err/Ok(None) as "no old image" would
191 // silently over-count. This probe stays outside the transaction so we never
192 // hold a DB connection across an S3 round-trip.
193 let mut replace_old_size: Option<i64> = None;
194 let old_key_to_delete: Option<String> = if let Some(ref old_url) = project.cover_image_url
195 && let Some(old_key) = storage::extract_s3_key_from_url(
196 old_url,
197 state.config.cdn_base_url.as_deref(),
198 Some(s3.bucket()),
199 state.config.storage.as_ref().map(|c| c.endpoint.as_str()),
200 )
201 {
202 match s3.object_size(&old_key).await {
203 Ok(Some(old_size)) if old_size > 0 => {
204 replace_old_size = Some(old_size);
205 Some(old_key)
206 }
207 Ok(Some(_)) | Ok(None) => {
208 // Old URL parsed but the object is gone (or zero-sized): treat as a
209 // fresh upload — nothing to refund. Still queue the old key for
210 // deletion in case of S3 eventual-consistency.
211 Some(old_key)
212 }
213 Err(e) => {
214 // S3 probe failed (transient). Refuse to write — letting a probe failure
215 // silently over-count storage on every replace is the bug this branch exists to prevent.
216 s3.delete_object(&req.s3_key).await.ok();
217 tracing::warn!(key = %old_key, error = ?e, "S3 probe failed during project image replace");
218 return Err(AppError::ServiceUnavailable(
219 "Could not verify previous image. Please try again.".to_string(),
220 ));
221 }
222 }
223 } else {
224 None
225 };
226
227 // Build permanent URL (async, before the tx).
228 let image_url = storage::build_project_image_url(
229 s3.as_ref(),
230 state.config.cdn_base_url.as_deref(),
231 &req.s3_key,
232 ).await?;
233
234 // Storage credit + project image URL UPDATE in ONE transaction. A rollback
235 // restores the counter, so the previous compensating `rollback_and_orphan`
236 // math (with a second S3 probe and swallowed `.ok()`s) is gone. `commit_upload`
237 // stays AFTER the commit. `Ok(false)` = ownership filter no-matched (project
238 // deleted/transferred mid-flight); the tx rolled back, nothing charged.
239 let committed: Result<bool> = async {
240 let mut tx = state.db.begin().await?;
241 db::creator_tiers::try_apply_storage_on(
242 &mut tx, user.id, replace_old_size, file_size_bytes, max_storage,
243 ).await?;
244 let ok = db::projects::update_project_image_url(&mut *tx, req.project_id, user.id, &image_url).await?;
245 if !ok {
246 return Ok(false);
247 }
248 tx.commit().await?;
249 Ok(true)
250 }
251 .await;
252
253 match committed {
254 Err(e) => {
255 // tx rolled back — counter unchanged. Orphan-queue the new key for cleanup.
256 super::enqueue_s3_orphan(&state.db, &req.s3_key, "project_image_update_failed").await;
257 return Err(e);
258 }
259 Ok(false) => {
260 super::enqueue_s3_orphan(&state.db, &req.s3_key, "project_image_update_failed").await;
261 return Err(AppError::BadRequest(
262 "Project was modified concurrently. Please try uploading again.".to_string(),
263 ));
264 }
265 Ok(true) => {}
266 }
267
268 // Clear the pending upload record now that the upload is committed
269 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
270
271 // Enqueue old S3 object for durable deletion now that the new URL is committed.
272 if let Some(old_key) = old_key_to_delete
273 && let Err(e) = db::pending_s3_deletions::enqueue_deletions(
274 &state.db,
275 &[(old_key.clone(), "main".to_string())],
276 "project_image_replace",
277 ).await
278 {
279 tracing::warn!(key = %old_key, error = ?e, "failed to enqueue old project image for deletion");
280 }
281
282 // Scan enqueue AFTER the DB write commits (Phase 5 chronic fix — the same
283 // ordering rule that uploads/versions/media follow via `commit_upload`).
284 commit_upload(
285 &state,
286 CommitTarget::ProjectImage(req.project_id),
287 &req.s3_key,
288 FileType::Cover,
289 user.id,
290 file_size_bytes,
291 ).await?;
292
293 // Bump cache
294 db::projects::bump_cache_generation(&state.db, req.project_id).await?;
295
296 tracing::info!(
297 "Project image confirmed: project={}, key={}, size={}",
298 req.project_id,
299 req.s3_key,
300 file_size_bytes
301 );
302
303 Ok(Json(ProjectImageConfirmResponse {
304 success: true,
305 image_url,
306 }))
307 }
308
309 /// Generate a presigned URL for uploading an item image (logo/cover)
310 ///
311 /// POST /api/items/image/presign
312 ///
313 /// Requires authentication. User must own the item.
314 #[tracing::instrument(skip_all, name = "storage::item_image_presign", fields(user_id = %user.id))]
315 pub(super) async fn item_image_presign(
316 State(state): State<AppState>,
317 AuthUser(user): AuthUser,
318 Json(req): Json<ItemImagePresignRequest>,
319 ) -> Result<impl IntoResponse> {
320 user.check_not_suspended()?;
321 let s3 = state.require_s3()?;
322
323 let file_type = FileType::Cover;
324 S3Client::validate_content_type(file_type, &req.content_type)?;
325 S3Client::validate_extension(file_type, &req.file_name)?;
326
327 // Verify user owns the item
328 let owner = db::items::get_item_owner(&state.db, req.item_id)
329 .await?
330 .ok_or(AppError::NotFound)?;
331
332 if owner != user.id {
333 return Err(AppError::Forbidden);
334 }
335
336 // Early quota check
337 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
338
339 let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name);
340
341 // Track the pending upload so the reaper can clean it up if never confirmed
342 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
343
344 let expires_in = 3600;
345 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None)
346 .await
347 .context("presign upload for item image")?;
348
349 Ok(Json(PresignUploadResponse {
350 upload_url,
351 s3_key,
352 expires_in,
353 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
354 max_file_bytes: None,
355 }))
356 }
357
358 /// Confirm an item image upload, scan, store URL
359 ///
360 /// POST /api/items/image/confirm
361 ///
362 /// Requires authentication. User must own the item.
363 #[tracing::instrument(skip_all, name = "storage::item_image_confirm", fields(user_id = %user.id))]
364 pub(super) async fn item_image_confirm(
365 State(state): State<AppState>,
366 AuthUser(user): AuthUser,
367 Json(req): Json<ItemImageConfirmRequest>,
368 ) -> Result<impl IntoResponse> {
369 user.check_not_suspended()?;
370 let s3 = state.require_s3()?;
371
372 // Verify user owns the item
373 let owner = db::items::get_item_owner(&state.db, req.item_id)
374 .await?
375 .ok_or(AppError::NotFound)?;
376
377 if owner != user.id {
378 return Err(AppError::Forbidden);
379 }
380
381 // Validate S3 key belongs to this user + item (prevent cross-user file reference)
382 let expected_prefix = format!("{}/{}/", user.id, req.item_id);
383 if !req.s3_key.starts_with(&expected_prefix) {
384 return Err(AppError::BadRequest(
385 "Invalid upload key".to_string(),
386 ));
387 }
388
389 // Verify the object exists in S3
390 if !s3.object_exists(&req.s3_key).await? {
391 return Err(AppError::BadRequest(
392 "Upload not found. Please try uploading again.".to_string(),
393 ));
394 }
395
396 // Enforce file size limit
397 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
398 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
399 })?;
400 if file_size_bytes as u64 > FileType::Cover.max_size() {
401 s3.delete_object(&req.s3_key).await.ok();
402 return Err(AppError::BadRequest(format!(
403 "File exceeds maximum size of {} MB",
404 FileType::Cover.max_size() / (1024 * 1024)
405 )));
406 }
407
408 // Enforce tier-based limits
409 let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await {
410 Ok(max) => max,
411 Err(e) => {
412 s3.delete_object(&req.s3_key).await.ok();
413 return Err(e);
414 }
415 };
416
417 // Idempotency: if cover_s3_key already matches, return success (no-op).
418 // Otherwise capture the existing cover for atomic replacement below.
419 let existing_item = db::items::get_item_by_id(&state.db, req.item_id).await?;
420 if let Some(ref item) = existing_item
421 && item.cover_s3_key.as_deref() == Some(&req.s3_key)
422 {
423 // Still clear pending_uploads — orphan reaper would otherwise delete
424 // the live S3 object 24h later (Run #7 HIGH-1).
425 if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await {
426 tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm");
427 }
428 return Ok(Json(super::images::ProjectImageConfirmResponse {
429 success: true,
430 image_url: item.cover_image_url.clone().unwrap_or_default(),
431 }));
432 }
433
434 // Old cover key+size come straight from the already-loaded item row (no S3
435 // probe needed). We replace only when the old object has a real size; split
436 // that decision once into the replace-size and the key to clean up.
437 let (replace_old_size, old_key_to_delete): (Option<i64>, Option<String>) =
438 match existing_item.as_ref().and_then(|i| Some((i.cover_s3_key.clone()?, i.cover_file_size_bytes.unwrap_or(0)))) {
439 Some((key, size)) if size > 0 => (Some(size), Some(key)),
440 _ => (None, None),
441 };
442
443 let image_url = storage::build_project_image_url(
444 s3.as_ref(),
445 state.config.cdn_base_url.as_deref(),
446 &req.s3_key,
447 ).await?;
448
449 // Storage credit + item cover UPDATE in ONE transaction (Run #7 HIGH-2 made
450 // these atomic via compensating actions; this makes them atomic via a real
451 // tx — a rollback restores the counter with no swallowed-`.ok()` math).
452 // `commit_upload` stays AFTER the commit. `Ok(false)` = ownership filter
453 // no-matched (item deleted/moved mid-flight); the tx rolled back, nothing charged.
454 let committed: Result<bool> = async {
455 let mut tx = state.db.begin().await?;
456 db::creator_tiers::try_apply_storage_on(
457 &mut tx, user.id, replace_old_size, file_size_bytes, max_storage,
458 ).await?;
459 let ok = db::items::update_item_cover(
460 &mut *tx, req.item_id, user.id, &image_url, &req.s3_key, file_size_bytes,
461 ).await?;
462 if !ok {
463 return Ok(false);
464 }
465 tx.commit().await?;
466 Ok(true)
467 }
468 .await;
469
470 match committed {
471 Err(e) => {
472 super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_image_update_failed").await;
473 return Err(e);
474 }
475 Ok(false) => {
476 super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_image_update_failed").await;
477 return Err(AppError::BadRequest(
478 "Item was modified concurrently. Please try uploading again.".to_string(),
479 ));
480 }
481 Ok(true) => {}
482 }
483
484 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
485
486 if let Some(old_key) = old_key_to_delete
487 && let Err(e) = db::pending_s3_deletions::enqueue_deletions(
488 &state.db,
489 &[(old_key.clone(), "main".to_string())],
490 "item_image_replace",
491 ).await
492 {
493 tracing::warn!(key = %old_key, error = ?e, "failed to enqueue old item cover for deletion");
494 }
495
496 // Scan enqueue + scan_status flip AFTER the DB write — same ordering rule
497 // as uploads/versions/media. The Run #6 audit caught this same bug here.
498 commit_upload(
499 &state,
500 CommitTarget::ItemImage(req.item_id),
501 &req.s3_key,
502 FileType::Cover,
503 user.id,
504 file_size_bytes,
505 ).await?;
506
507 // Bump project cache
508 if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await?
509 && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await
510 {
511 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after image upload");
512 }
513
514 tracing::info!(
515 "Item image confirmed: item={}, key={}, size={}",
516 req.item_id,
517 req.s3_key,
518 file_size_bytes
519 );
520
521 Ok(Json(ProjectImageConfirmResponse {
522 success: true,
523 image_url,
524 }))
525 }
526