Skip to main content

max / makenotwork

20.2 KB · 551 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")]
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")]
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 // Atomically replace storage (decrement old, increment new) BEFORE writing the DB record.
189 // Probe the old S3 object's size; if the row references an old image we MUST be able to
190 // determine its size or the storage counter drifts on every replacement. Falling into the
191 // "no old image" branch on Err / Ok(None) would silently over-count.
192 let old_key_to_delete: Option<String> = if let Some(ref old_url) = project.cover_image_url
193 && let Some(old_key) = storage::extract_s3_key_from_url(
194 old_url,
195 state.config.cdn_base_url.as_deref(),
196 Some(s3.bucket()),
197 state.config.storage.as_ref().map(|c| c.endpoint.as_str()),
198 )
199 {
200 match s3.object_size(&old_key).await {
201 Ok(Some(old_size)) if old_size > 0 => {
202 if let Err(e) = db::creator_tiers::try_replace_storage(&state.db, user.id, old_size, file_size_bytes, max_storage).await {
203 s3.delete_object(&req.s3_key).await.ok();
204 return Err(e);
205 }
206 Some(old_key)
207 }
208 Ok(Some(_)) | Ok(None) => {
209 // Old URL parsed but the object is gone (or zero-sized): treat as a
210 // fresh upload — there's nothing to refund, so just increment.
211 if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await {
212 s3.delete_object(&req.s3_key).await.ok();
213 return Err(e);
214 }
215 Some(old_key) // still queue deletion (it returned None, but try anyway in case of S3 eventual-consistency)
216 }
217 Err(e) => {
218 // S3 probe failed (transient). Refuse to write — letting a probe failure
219 // silently over-count storage on every replace is the bug this branch exists to prevent.
220 s3.delete_object(&req.s3_key).await.ok();
221 tracing::warn!(key = %old_key, error = ?e, "S3 probe failed during project image replace");
222 return Err(AppError::ServiceUnavailable(
223 "Could not verify previous image. Please try again.".to_string(),
224 ));
225 }
226 }
227 } else {
228 // No old image — just increment
229 if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await {
230 s3.delete_object(&req.s3_key).await.ok();
231 return Err(e);
232 }
233 None
234 };
235
236 // Clear the pending upload record now that the upload is confirmed
237 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
238
239 // Build permanent URL
240 let image_url = storage::build_project_image_url(
241 s3.as_ref(),
242 state.config.cdn_base_url.as_deref(),
243 &req.s3_key,
244 ).await?;
245
246 // Store URL in database. By this point we've committed storage credit + cleared
247 // pending_uploads, so a failure here must roll both back AND queue the new key
248 // for orphan deletion — otherwise the S3 object leaks with no reaper coverage
249 // and the storage counter stays inflated. Treat Ok(false) the same as Err: the
250 // ownership filter no-matched (project was deleted or transferred mid-flight)
251 // and no row references the new key.
252 let rollback_and_orphan = || async {
253 match old_key_to_delete.as_deref() {
254 Some(_) => {
255 // We did a replace earlier. Determine old size from a fresh probe;
256 // if probe fails, best-effort decrement of just the new size.
257 if let Ok(Some(old_size)) = s3.object_size(old_key_to_delete.as_deref().unwrap_or("")).await
258 && old_size > 0
259 {
260 db::creator_tiers::try_replace_storage(
261 &state.db, user.id, file_size_bytes, old_size, i64::MAX,
262 ).await.ok();
263 } else {
264 db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size_bytes).await.ok();
265 }
266 }
267 None => {
268 db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size_bytes).await.ok();
269 }
270 }
271 super::enqueue_s3_orphan(&state.db, &req.s3_key, "project_image_update_failed").await;
272 };
273
274 match db::projects::update_project_image_url(&state.db, req.project_id, user.id, &image_url).await {
275 Err(e) => {
276 rollback_and_orphan().await;
277 return Err(e.into());
278 }
279 Ok(false) => {
280 rollback_and_orphan().await;
281 return Err(AppError::BadRequest(
282 "Project was modified concurrently. Please try uploading again.".to_string(),
283 ));
284 }
285 Ok(true) => {}
286 }
287
288 // Enqueue old S3 object for durable deletion now that the new URL is committed.
289 if let Some(old_key) = old_key_to_delete
290 && let Err(e) = db::pending_s3_deletions::enqueue_deletions(
291 &state.db,
292 &[(old_key.clone(), "main".to_string())],
293 "project_image_replace",
294 ).await
295 {
296 tracing::warn!(key = %old_key, error = ?e, "failed to enqueue old project image for deletion");
297 }
298
299 // Scan enqueue AFTER the DB write commits (Phase 5 chronic fix — the same
300 // ordering rule that uploads/versions/media follow via `commit_upload`).
301 commit_upload(
302 &state,
303 CommitTarget::ProjectImage(req.project_id),
304 &req.s3_key,
305 FileType::Cover,
306 user.id,
307 file_size_bytes,
308 ).await?;
309
310 // Bump cache
311 db::projects::bump_cache_generation(&state.db, req.project_id).await?;
312
313 tracing::info!(
314 "Project image confirmed: project={}, key={}, size={}",
315 req.project_id,
316 req.s3_key,
317 file_size_bytes
318 );
319
320 Ok(Json(ProjectImageConfirmResponse {
321 success: true,
322 image_url,
323 }))
324 }
325
326 /// Generate a presigned URL for uploading an item image (logo/cover)
327 ///
328 /// POST /api/items/image/presign
329 ///
330 /// Requires authentication. User must own the item.
331 #[tracing::instrument(skip_all, name = "storage::item_image_presign")]
332 pub(super) async fn item_image_presign(
333 State(state): State<AppState>,
334 AuthUser(user): AuthUser,
335 Json(req): Json<ItemImagePresignRequest>,
336 ) -> Result<impl IntoResponse> {
337 user.check_not_suspended()?;
338 let s3 = state.require_s3()?;
339
340 let file_type = FileType::Cover;
341 S3Client::validate_content_type(file_type, &req.content_type)?;
342 S3Client::validate_extension(file_type, &req.file_name)?;
343
344 // Verify user owns the item
345 let owner = db::items::get_item_owner(&state.db, req.item_id)
346 .await?
347 .ok_or(AppError::NotFound)?;
348
349 if owner != user.id {
350 return Err(AppError::Forbidden);
351 }
352
353 // Early quota check
354 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
355
356 let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name);
357
358 // Track the pending upload so the reaper can clean it up if never confirmed
359 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
360
361 let expires_in = 3600;
362 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None)
363 .await
364 .context("presign upload for item image")?;
365
366 Ok(Json(PresignUploadResponse {
367 upload_url,
368 s3_key,
369 expires_in,
370 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
371 max_file_bytes: None,
372 }))
373 }
374
375 /// Confirm an item image upload, scan, store URL
376 ///
377 /// POST /api/items/image/confirm
378 ///
379 /// Requires authentication. User must own the item.
380 #[tracing::instrument(skip_all, name = "storage::item_image_confirm")]
381 pub(super) async fn item_image_confirm(
382 State(state): State<AppState>,
383 AuthUser(user): AuthUser,
384 Json(req): Json<ItemImageConfirmRequest>,
385 ) -> Result<impl IntoResponse> {
386 user.check_not_suspended()?;
387 let s3 = state.require_s3()?;
388
389 // Verify user owns the item
390 let owner = db::items::get_item_owner(&state.db, req.item_id)
391 .await?
392 .ok_or(AppError::NotFound)?;
393
394 if owner != user.id {
395 return Err(AppError::Forbidden);
396 }
397
398 // Validate S3 key belongs to this user + item (prevent cross-user file reference)
399 let expected_prefix = format!("{}/{}/", user.id, req.item_id);
400 if !req.s3_key.starts_with(&expected_prefix) {
401 return Err(AppError::BadRequest(
402 "Invalid upload key".to_string(),
403 ));
404 }
405
406 // Verify the object exists in S3
407 if !s3.object_exists(&req.s3_key).await? {
408 return Err(AppError::BadRequest(
409 "Upload not found. Please try uploading again.".to_string(),
410 ));
411 }
412
413 // Enforce file size limit
414 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
415 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
416 })?;
417 if file_size_bytes as u64 > FileType::Cover.max_size() {
418 s3.delete_object(&req.s3_key).await.ok();
419 return Err(AppError::BadRequest(format!(
420 "File exceeds maximum size of {} MB",
421 FileType::Cover.max_size() / (1024 * 1024)
422 )));
423 }
424
425 // Enforce tier-based limits
426 let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await {
427 Ok(max) => max,
428 Err(e) => {
429 s3.delete_object(&req.s3_key).await.ok();
430 return Err(e);
431 }
432 };
433
434 // Idempotency: if cover_s3_key already matches, return success (no-op).
435 // Otherwise capture the existing cover for atomic replacement below.
436 let existing_item = db::items::get_item_by_id(&state.db, req.item_id).await?;
437 if let Some(ref item) = existing_item
438 && item.cover_s3_key.as_deref() == Some(&req.s3_key)
439 {
440 // Still clear pending_uploads — orphan reaper would otherwise delete
441 // the live S3 object 24h later (Run #7 HIGH-1).
442 if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await {
443 tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm");
444 }
445 return Ok(Json(super::images::ProjectImageConfirmResponse {
446 success: true,
447 image_url: item.cover_image_url.clone().unwrap_or_default(),
448 }));
449 }
450
451 // Atomically replace storage (decrement old, increment new) BEFORE writing the DB record.
452 // Old S3 deletion is enqueued only after the swap + UPDATE commit.
453 let old_cover = existing_item.as_ref().and_then(|i| {
454 let key = i.cover_s3_key.clone()?;
455 let size = i.cover_file_size_bytes.unwrap_or(0);
456 Some((key, size))
457 });
458 let old_key_to_delete: Option<String> = if let Some((ref old_key, old_size)) = old_cover
459 && old_size > 0
460 {
461 if let Err(e) = db::creator_tiers::try_replace_storage(&state.db, user.id, old_size, file_size_bytes, max_storage).await {
462 s3.delete_object(&req.s3_key).await.ok();
463 return Err(e);
464 }
465 Some(old_key.clone())
466 } else {
467 if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await {
468 s3.delete_object(&req.s3_key).await.ok();
469 return Err(e);
470 }
471 None
472 };
473
474 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
475
476 let image_url = storage::build_project_image_url(
477 s3.as_ref(),
478 state.config.cdn_base_url.as_deref(),
479 &req.s3_key,
480 ).await?;
481
482 // Same rollback discipline as project_image_confirm — Run #7 HIGH-2.
483 // Ok(false) means the ownership filter no-matched (item deleted or moved
484 // between projects mid-flight); treat identically to Err.
485 let item_cover_rollback = || async {
486 if let Some((_, old_size)) = old_cover.as_ref()
487 && *old_size > 0
488 {
489 db::creator_tiers::try_replace_storage(
490 &state.db, user.id, file_size_bytes, *old_size, i64::MAX,
491 ).await.ok();
492 } else {
493 db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size_bytes).await.ok();
494 }
495 super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_image_update_failed").await;
496 };
497 match db::items::update_item_cover(&state.db, req.item_id, user.id, &image_url, &req.s3_key, file_size_bytes).await {
498 Err(e) => {
499 item_cover_rollback().await;
500 return Err(e);
501 }
502 Ok(false) => {
503 item_cover_rollback().await;
504 return Err(AppError::BadRequest(
505 "Item was modified concurrently. Please try uploading again.".to_string(),
506 ));
507 }
508 Ok(true) => {}
509 }
510
511 if let Some(old_key) = old_key_to_delete
512 && let Err(e) = db::pending_s3_deletions::enqueue_deletions(
513 &state.db,
514 &[(old_key.clone(), "main".to_string())],
515 "item_image_replace",
516 ).await
517 {
518 tracing::warn!(key = %old_key, error = ?e, "failed to enqueue old item cover for deletion");
519 }
520
521 // Scan enqueue + scan_status flip AFTER the DB write — same ordering rule
522 // as uploads/versions/media. The Run #6 audit caught this same bug here.
523 commit_upload(
524 &state,
525 CommitTarget::ItemImage(req.item_id),
526 &req.s3_key,
527 FileType::Cover,
528 user.id,
529 file_size_bytes,
530 ).await?;
531
532 // Bump project cache
533 if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await?
534 && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await
535 {
536 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after image upload");
537 }
538
539 tracing::info!(
540 "Item image confirmed: item={}, key={}, size={}",
541 req.item_id,
542 req.s3_key,
543 file_size_bytes
544 );
545
546 Ok(Json(ProjectImageConfirmResponse {
547 success: true,
548 image_url,
549 }))
550 }
551