Skip to main content

max / makenotwork

20.8 KB · 607 lines History Blame Raw
1 //! Presigned upload / confirm / delete / reorder handlers for item & project
2 //! image galleries (launchplan S.1).
3 //!
4 //! Reuses the cover-image S3 path wholesale (validation, presign, the atomic
5 //! storage-credit transaction, scan enqueue ordering, S3-deletion queue). The
6 //! one difference from cover upload: a gallery image is ADD-only, confirm
7 //! inserts a new row and is a pure storage increment (no old-key probe), and a
8 //! separate delete decrements storage. cover_image_url is never touched.
9
10 use axum::{
11 Json,
12 extract::{Path, State},
13 response::IntoResponse,
14 };
15 use serde::{Deserialize, Serialize};
16 use sqlx::PgPool;
17 use uuid::Uuid;
18
19 use crate::{
20 AppStorage, Scanning,
21 auth::AuthUser,
22 config::Config,
23 db::{self, ImageId, ItemId, ProjectId, UserId},
24 error::{AppError, Result, ResultExt},
25 storage::{self, CACHE_CONTROL_IMMUTABLE, FileType, S3Client},
26 };
27
28 use super::{CommitTarget, PresignUploadResponse, commit_upload};
29
30 /// Which kind of entity a gallery image hangs off.
31 #[derive(Debug, Clone, Copy)]
32 enum GalleryTarget {
33 Item,
34 Project,
35 }
36
37 impl GalleryTarget {
38 fn parse(s: &str) -> Result<Self> {
39 match s {
40 "item" => Ok(GalleryTarget::Item),
41 "project" => Ok(GalleryTarget::Project),
42 _ => Err(AppError::BadRequest(
43 "Invalid gallery target type".to_string(),
44 )),
45 }
46 }
47 }
48
49 /// Verify `user` owns the target entity. Returns the parsed kind. NotFound if
50 /// the entity does not exist, Forbidden if owned by someone else.
51 async fn require_owned(
52 db: &PgPool,
53 target: GalleryTarget,
54 target_id: Uuid,
55 user_id: UserId,
56 ) -> Result<()> {
57 let owner = match target {
58 GalleryTarget::Item => db::items::get_item_owner(db, ItemId::from(target_id)).await?,
59 GalleryTarget::Project => db::projects::get_project_by_id(db, ProjectId::from(target_id))
60 .await?
61 .map(|p| p.user_id),
62 };
63 match owner {
64 Some(o) if o == user_id => Ok(()),
65 Some(_) => Err(AppError::Forbidden),
66 None => Err(AppError::NotFound),
67 }
68 }
69
70 // List (owner-only, for the wizard manager)
71
72 #[derive(Debug, Serialize)]
73 struct GalleryListItem {
74 id: ImageId,
75 image_url: String,
76 alt: String,
77 }
78
79 /// GET /api/gallery/list/{target_type}/{target_id}, current gallery, owner-only.
80 /// Drives the wizard manager (the public page renders the same rows server-side).
81 #[tracing::instrument(skip_all, name = "storage::gallery_list", fields(user_id = %user.id, %target_type, %target_id))]
82 pub(super) async fn gallery_list(
83 State(db): State<PgPool>,
84 AuthUser(user): AuthUser,
85 Path((target_type, target_id)): Path<(String, Uuid)>,
86 ) -> Result<impl IntoResponse> {
87 let target = GalleryTarget::parse(&target_type)?;
88 require_owned(&db, target, target_id, user.id).await?;
89
90 let rows = match target {
91 GalleryTarget::Item => {
92 db::gallery_images::list_for_item(&db, ItemId::from(target_id)).await?
93 }
94 GalleryTarget::Project => {
95 db::gallery_images::list_for_project(&db, ProjectId::from(target_id)).await?
96 }
97 };
98 let items: Vec<GalleryListItem> = rows
99 .into_iter()
100 .map(|g| GalleryListItem {
101 id: g.id,
102 image_url: g.image_url,
103 alt: g.alt,
104 })
105 .collect();
106 Ok(Json(items))
107 }
108
109 // Presign
110
111 #[derive(Debug, Deserialize)]
112 pub(super) struct GalleryPresignRequest {
113 pub target_type: String,
114 pub target_id: Uuid,
115 pub file_name: String,
116 pub content_type: String,
117 /// Optional declared size; when present it is signed into the presigned
118 /// URL's `Content-Length` so S3 rejects oversized PUTs at the protocol level.
119 #[serde(default)]
120 pub file_size_bytes: Option<i64>,
121 }
122
123 /// POST /api/gallery/presign, presign a gallery image upload.
124 #[tracing::instrument(skip_all, name = "storage::gallery_presign", fields(user_id = %user.id))]
125 pub(super) async fn gallery_presign(
126 State(db): State<PgPool>,
127 State(storage): State<AppStorage>,
128 AuthUser(user): AuthUser,
129 Json(req): Json<GalleryPresignRequest>,
130 ) -> Result<impl IntoResponse> {
131 user.check_not_suspended()?;
132 let s3 = storage.require_s3()?;
133 let target = GalleryTarget::parse(&req.target_type)?;
134
135 let file_type = FileType::Cover;
136 S3Client::validate_content_type(file_type, &req.content_type)?;
137 S3Client::validate_extension(file_type, &req.file_name)?;
138
139 require_owned(&db, target, req.target_id, user.id).await?;
140
141 // Early per-entity cap check (authoritative re-check happens at confirm).
142 let count = match target {
143 GalleryTarget::Item => {
144 db::gallery_images::count_for_item(&db, ItemId::from(req.target_id)).await?
145 }
146 GalleryTarget::Project => {
147 db::gallery_images::count_for_project(&db, ProjectId::from(req.target_id)).await?
148 }
149 };
150 if count >= db::gallery_images::MAX_GALLERY_IMAGES {
151 return Err(AppError::BadRequest(format!(
152 "Gallery is full (max {} images)",
153 db::gallery_images::MAX_GALLERY_IMAGES
154 )));
155 }
156
157 db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?;
158
159 // Validate the declared size (if any) before signing it into Content-Length.
160 super::validate_declared_upload_size(req.file_size_bytes, file_type, None)?;
161
162 // Staging key (unserved); the scan worker promotes it to the content key and
163 // rebuilds the row's public `image_url` on a Clean verdict (C1). Its random
164 // uuid also keeps multiple gallery uploads from colliding.
165 let s3_key = S3Client::generate_staging_key(&req.file_name);
166
167 db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?;
168
169 let expires_in = 3600;
170 let upload_url = s3
171 .presign_upload(
172 &s3_key,
173 &req.content_type,
174 Some(expires_in),
175 Some(CACHE_CONTROL_IMMUTABLE),
176 req.file_size_bytes,
177 )
178 .await
179 .context("presign upload for gallery image")?;
180
181 Ok(Json(PresignUploadResponse {
182 upload_url,
183 s3_key: s3_key.into_string(),
184 expires_in,
185 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
186 max_file_bytes: None,
187 }))
188 }
189
190 // Confirm
191
192 #[derive(Debug, Deserialize)]
193 pub(super) struct GalleryConfirmRequest {
194 pub target_type: String,
195 pub target_id: Uuid,
196 pub s3_key: String,
197 #[serde(default)]
198 pub alt: String,
199 }
200
201 #[derive(Debug, Serialize)]
202 pub(super) struct GalleryConfirmResponse {
203 pub success: bool,
204 pub id: ImageId,
205 pub image_url: String,
206 pub alt: String,
207 }
208
209 /// Outcome of the confirm transaction: a fresh insert, or the pre-existing row
210 /// found on a replayed confirm (STOR-S1 idempotency, no second insert, no
211 /// second storage charge).
212 enum GalleryOutcome {
213 Inserted(ImageId),
214 Existing(db::gallery_images::GalleryImage),
215 }
216
217 /// POST /api/gallery/confirm, finalize a gallery image upload.
218 #[tracing::instrument(skip_all, name = "storage::gallery_confirm", fields(user_id = %user.id))]
219 pub(super) async fn gallery_confirm(
220 State(db): State<PgPool>,
221 State(storage): State<AppStorage>,
222 State(config): State<Config>,
223 State(scanning): State<Scanning>,
224 AuthUser(user): AuthUser,
225 Json(req): Json<GalleryConfirmRequest>,
226 ) -> Result<impl IntoResponse> {
227 user.check_not_suspended()?;
228 let s3 = storage.require_s3()?;
229 let target = GalleryTarget::parse(&req.target_type)?;
230
231 require_owned(&db, target, req.target_id, user.id).await?;
232
233 // Ownership of the staging key is proved inside the confirm transaction below
234 // (after the replay short-circuit) via `pending_uploads`, a `staging/{uuid}`
235 // key carries no entity in its path for a prefix check to bind against.
236
237 if !s3.object_exists(&req.s3_key).await? {
238 return Err(AppError::BadRequest(
239 "Upload not found. Please try uploading again.".to_string(),
240 ));
241 }
242
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 > FileType::Cover.max_size() {
249 super::enqueue_s3_orphan(
250 &db,
251 &req.s3_key,
252 crate::storage::S3Bucket::Main,
253 "gallery_upload_rejected",
254 )
255 .await;
256 return Err(AppError::BadRequest(format!(
257 "File exceeds maximum size of {} MB",
258 FileType::Cover.max_size() / (1024 * 1024)
259 )));
260 }
261
262 // Authoritative cap re-check (presign's was best-effort/UX).
263 let count = match target {
264 GalleryTarget::Item => {
265 db::gallery_images::count_for_item(&db, ItemId::from(req.target_id)).await?
266 }
267 GalleryTarget::Project => {
268 db::gallery_images::count_for_project(&db, ProjectId::from(req.target_id)).await?
269 }
270 };
271 if count >= db::gallery_images::MAX_GALLERY_IMAGES {
272 super::enqueue_s3_orphan(
273 &db,
274 &req.s3_key,
275 crate::storage::S3Bucket::Main,
276 "gallery_upload_rejected",
277 )
278 .await;
279 return Err(AppError::BadRequest(format!(
280 "Gallery is full (max {} images)",
281 db::gallery_images::MAX_GALLERY_IMAGES
282 )));
283 }
284
285 let max_storage = match db::creator_tiers::check_upload_allowed(
286 &db,
287 user.id,
288 FileType::Cover,
289 file_size_bytes,
290 )
291 .await
292 {
293 Ok(max) => max,
294 Err(e) => {
295 super::enqueue_s3_orphan(
296 &db,
297 &req.s3_key,
298 crate::storage::S3Bucket::Main,
299 "gallery_upload_rejected",
300 )
301 .await;
302 return Err(e);
303 }
304 };
305
306 let image_url = storage::build_project_image_url(&config.cdn_base_url, &req.s3_key);
307
308 let alt = req.alt.trim().to_string();
309
310 // Advisory-lock key for serializing concurrent confirms on this gallery:
311 // class separates item vs project; obj folds the target UUID to i32 (a
312 // collision only over-serializes two unrelated galleries, never under-).
313 let gallery_lock_class: i32 = match target {
314 GalleryTarget::Item => 0,
315 GalleryTarget::Project => 1,
316 };
317 let gallery_lock_obj: i32 = {
318 let b = req.target_id.as_bytes();
319 i32::from_le_bytes([b[0], b[1], b[2], b[3]])
320 ^ i32::from_le_bytes([b[4], b[5], b[6], b[7]])
321 ^ i32::from_le_bytes([b[8], b[9], b[10], b[11]])
322 ^ i32::from_le_bytes([b[12], b[13], b[14], b[15]])
323 };
324
325 // Storage increment + row INSERT in ONE transaction (gallery is add-only, so
326 // a pure increment, no old-key replacement). A rollback restores the counter.
327 // Returns the existing row on a replayed confirm (idempotent, no re-charge)
328 // or the freshly-inserted id otherwise.
329 let committed: Result<GalleryOutcome> = async {
330 let mut tx = db.begin().await?;
331 // Serialize concurrent confirms for this gallery and re-count INSIDE the
332 // tx, so two inserts that both passed the best-effort pre-check above
333 // can't push it over MAX_GALLERY_IMAGES (Run #14 Storage LOW). A failure
334 // here routes through the orphan-enqueue cleanup below.
335 sqlx::query("SELECT pg_advisory_xact_lock($1, $2)")
336 .bind(gallery_lock_class)
337 .bind(gallery_lock_obj)
338 .execute(&mut *tx)
339 .await?;
340 // Idempotency guard (STOR-S1): a replayed confirm for an s3_key already
341 // recorded must not insert a second row or charge storage again. The
342 // advisory lock above serializes confirms for this gallery, so this
343 // check-then-insert is race-safe; the UNIQUE index on s3_key (migration
344 // 147) is the durable backstop against any cross-process gap.
345 let existing = match target {
346 GalleryTarget::Item => {
347 db::gallery_images::find_for_item_by_key(
348 &mut *tx,
349 ItemId::from(req.target_id),
350 &req.s3_key,
351 )
352 .await?
353 }
354 GalleryTarget::Project => {
355 db::gallery_images::find_for_project_by_key(
356 &mut *tx,
357 ProjectId::from(req.target_id),
358 &req.s3_key,
359 )
360 .await?
361 }
362 };
363 if let Some(img) = existing {
364 return Ok(GalleryOutcome::Existing(img));
365 }
366 // Authorize a FRESH insert: the caller must have presigned this staging
367 // key (recorded against them in `pending_uploads`). Checked here, after
368 // the replay short-circuit, a replayed confirm's pending row was already
369 // consumed, so only new rows are gated. A `staging/{uuid}` key has no
370 // entity in its path, so this lookup (not a prefix check) is what stops a
371 // confirm from stealing another user's staging object.
372 if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? {
373 return Err(AppError::BadRequest("Invalid upload key".to_string()));
374 }
375 let count_in_tx = match target {
376 GalleryTarget::Item => {
377 db::gallery_images::count_for_item(&mut *tx, ItemId::from(req.target_id)).await?
378 }
379 GalleryTarget::Project => {
380 db::gallery_images::count_for_project(&mut *tx, ProjectId::from(req.target_id))
381 .await?
382 }
383 };
384 if count_in_tx >= db::gallery_images::MAX_GALLERY_IMAGES {
385 return Err(AppError::BadRequest(format!(
386 "Gallery is full (max {} images)",
387 db::gallery_images::MAX_GALLERY_IMAGES
388 )));
389 }
390 db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage)
391 .await?;
392 let id = match target {
393 GalleryTarget::Item => {
394 db::gallery_images::insert_for_item(
395 &mut *tx,
396 ItemId::from(req.target_id),
397 &req.s3_key,
398 &image_url,
399 &alt,
400 file_size_bytes,
401 )
402 .await?
403 }
404 GalleryTarget::Project => {
405 db::gallery_images::insert_for_project(
406 &mut *tx,
407 ProjectId::from(req.target_id),
408 &req.s3_key,
409 &image_url,
410 &alt,
411 file_size_bytes,
412 )
413 .await?
414 }
415 };
416 tx.commit().await?;
417 Ok(GalleryOutcome::Inserted(id))
418 }
419 .await;
420
421 let id = match committed {
422 // Replayed confirm: the object is already a live, recorded gallery row,
423 // nothing was charged or inserted, so do NOT orphan-enqueue (that key is
424 // in use). Return the existing row so the client sees the same success.
425 Ok(GalleryOutcome::Existing(img)) => {
426 return Ok(Json(GalleryConfirmResponse {
427 success: true,
428 id: img.id,
429 image_url: img.image_url,
430 alt: img.alt,
431 }));
432 }
433 Ok(GalleryOutcome::Inserted(id)) => id,
434 Err(e) => {
435 super::enqueue_s3_orphan(
436 &db,
437 &req.s3_key,
438 crate::storage::S3Bucket::Main,
439 "gallery_image_insert_failed",
440 )
441 .await;
442 return Err(e);
443 }
444 };
445
446 db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?;
447
448 // Scan enqueue AFTER the DB write commits (the chronic-ordering rule).
449 commit_upload(
450 &db,
451 scanning.scanner.as_ref(),
452 CommitTarget::GalleryImage(id.into()),
453 &req.s3_key,
454 FileType::Cover,
455 user.id,
456 file_size_bytes,
457 )
458 .await?;
459
460 bump_target_cache(&db, target, req.target_id).await;
461
462 Ok(Json(GalleryConfirmResponse {
463 success: true,
464 id,
465 image_url,
466 alt,
467 }))
468 }
469
470 // Delete
471
472 /// DELETE /api/gallery/image/{target_type}/{image_id}, remove one gallery image.
473 #[tracing::instrument(skip_all, name = "storage::gallery_delete", fields(user_id = %user.id, %target_type, %image_id))]
474 pub(super) async fn gallery_delete(
475 State(db): State<PgPool>,
476 AuthUser(user): AuthUser,
477 Path((target_type, image_id)): Path<(String, ImageId)>,
478 ) -> Result<impl IntoResponse> {
479 user.check_not_suspended()?;
480 let target = GalleryTarget::parse(&target_type)?;
481
482 // Delete the row (ownership-scoped) + decrement storage + enqueue the S3
483 // deletion, all in one tx; the row returns its s3_key + size so we never
484 // probe S3 for the decrement. The enqueue lives INSIDE the tx so a crash
485 // between commit and a post-commit enqueue can't orphan the object with no
486 // durable record (Run #18 Storage B6, matches delete_version / media_delete).
487 let deleted: Option<db::gallery_images::GalleryImage> = {
488 let mut tx = db.begin().await?;
489 let row = match target {
490 GalleryTarget::Item => {
491 db::gallery_images::delete_for_item(&mut *tx, image_id, user.id).await?
492 }
493 GalleryTarget::Project => {
494 db::gallery_images::delete_for_project(&mut *tx, image_id, user.id).await?
495 }
496 };
497 if let Some(ref r) = row {
498 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, r.file_size_bytes).await?;
499 db::pending_s3_deletions::enqueue_deletions(
500 &mut *tx,
501 // Gallery images (item_images/project_images) are CDN-served; the
502 // key may be a private staging key or a public content key, so
503 // enqueue both (see `both_bucket_delete`).
504 &crate::storage::both_bucket_delete(&r.s3_key),
505 "gallery_image_delete",
506 )
507 .await?;
508 tx.commit().await?;
509 }
510 // If row is None the tx drops (rolls back) untouched.
511 row
512 };
513
514 if deleted.is_none() {
515 return Err(AppError::NotFound);
516 }
517
518 Ok(Json(serde_json::json!({ "success": true })))
519 }
520
521 // Reorder
522
523 #[derive(Debug, Deserialize)]
524 pub(super) struct GalleryReorderRequest {
525 pub target_type: String,
526 pub target_id: Uuid,
527 pub ordered_ids: Vec<ImageId>,
528 }
529
530 /// POST /api/gallery/reorder, set gallery display order.
531 #[tracing::instrument(skip_all, name = "storage::gallery_reorder", fields(user_id = %user.id))]
532 pub(super) async fn gallery_reorder(
533 State(db): State<PgPool>,
534 AuthUser(user): AuthUser,
535 Json(req): Json<GalleryReorderRequest>,
536 ) -> Result<impl IntoResponse> {
537 user.check_not_suspended()?;
538 let target = GalleryTarget::parse(&req.target_type)?;
539 require_owned(&db, target, req.target_id, user.id).await?;
540
541 match target {
542 GalleryTarget::Item => {
543 db::gallery_images::reorder_item(&db, ItemId::from(req.target_id), &req.ordered_ids)
544 .await?;
545 }
546 GalleryTarget::Project => {
547 db::gallery_images::reorder_project(
548 &db,
549 ProjectId::from(req.target_id),
550 &req.ordered_ids,
551 )
552 .await?;
553 }
554 }
555
556 bump_target_cache(&db, target, req.target_id).await;
557 Ok(Json(serde_json::json!({ "success": true })))
558 }
559
560 /// Bump the public-page cache generation for the affected project (best-effort).
561 async fn bump_target_cache(db: &PgPool, target: GalleryTarget, target_id: Uuid) {
562 let project_id = match target {
563 GalleryTarget::Project => Some(ProjectId::from(target_id)),
564 GalleryTarget::Item => db::items::get_item_by_id(db, ItemId::from(target_id))
565 .await
566 .ok()
567 .flatten()
568 .map(|i| i.project_id),
569 };
570 if let Some(pid) = project_id
571 && let Err(e) = db::projects::bump_cache_generation(db, pid).await
572 {
573 tracing::warn!(project_id = %pid, error = ?e, "failed to bump cache generation after gallery change");
574 }
575 }
576
577 #[cfg(test)]
578 mod tests {
579 //! Gallery target parsing. This decides which ownership check runs, so a
580 //! wrong answer here points an authorization query at the wrong table.
581
582 use super::*;
583
584 #[test]
585 fn the_two_known_targets_parse() {
586 assert!(matches!(
587 GalleryTarget::parse("item"),
588 Ok(GalleryTarget::Item)
589 ));
590 assert!(matches!(
591 GalleryTarget::parse("project"),
592 Ok(GalleryTarget::Project)
593 ));
594 }
595
596 #[test]
597 fn anything_else_is_refused_rather_than_defaulted() {
598 // Defaulting an unknown target would run the wrong ownership check.
599 for s in ["", "Item", "ITEM", "items", "user", "../item"] {
600 assert!(
601 matches!(GalleryTarget::parse(s), Err(AppError::BadRequest(_))),
602 "{s:?} must not resolve to a target"
603 );
604 }
605 }
606 }
607