Skip to main content

max / makenotwork

16.2 KB · 442 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 extract::{Path, State},
12 response::IntoResponse,
13 Json,
14 };
15 use serde::{Deserialize, Serialize};
16 use uuid::Uuid;
17
18 use crate::{
19 auth::AuthUser,
20 db::{self, ItemId, ProjectId, UserId},
21 error::{AppError, Result, ResultExt},
22 storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
23 AppState,
24 };
25
26 use super::{commit_upload, CommitTarget, PresignUploadResponse};
27
28 /// Which kind of entity a gallery image hangs off.
29 #[derive(Debug, Clone, Copy)]
30 enum GalleryTarget {
31 Item,
32 Project,
33 }
34
35 impl GalleryTarget {
36 fn parse(s: &str) -> Result<Self> {
37 match s {
38 "item" => Ok(GalleryTarget::Item),
39 "project" => Ok(GalleryTarget::Project),
40 _ => Err(AppError::BadRequest("Invalid gallery target type".to_string())),
41 }
42 }
43 }
44
45 /// Verify `user` owns the target entity. Returns the parsed kind. NotFound if
46 /// the entity does not exist, Forbidden if owned by someone else.
47 async fn require_owned(
48 state: &AppState,
49 target: GalleryTarget,
50 target_id: Uuid,
51 user_id: UserId,
52 ) -> Result<()> {
53 let owner = match target {
54 GalleryTarget::Item => db::items::get_item_owner(&state.db, ItemId::from(target_id)).await?,
55 GalleryTarget::Project => db::projects::get_project_by_id(&state.db, ProjectId::from(target_id))
56 .await?
57 .map(|p| p.user_id),
58 };
59 match owner {
60 Some(o) if o == user_id => Ok(()),
61 Some(_) => Err(AppError::Forbidden),
62 None => Err(AppError::NotFound),
63 }
64 }
65
66 // ---------------------------------------------------------------------------
67 // List (owner-only, for the wizard manager)
68 // ---------------------------------------------------------------------------
69
70 #[derive(Debug, Serialize)]
71 struct GalleryListItem {
72 id: Uuid,
73 image_url: String,
74 alt: String,
75 }
76
77 /// GET /api/gallery/list/{target_type}/{target_id} — current gallery, owner-only.
78 /// Drives the wizard manager (the public page renders the same rows server-side).
79 #[tracing::instrument(skip_all, name = "storage::gallery_list", fields(user_id = %user.id, %target_type, %target_id))]
80 pub(super) async fn gallery_list(
81 State(state): State<AppState>,
82 AuthUser(user): AuthUser,
83 Path((target_type, target_id)): Path<(String, Uuid)>,
84 ) -> Result<impl IntoResponse> {
85 let target = GalleryTarget::parse(&target_type)?;
86 require_owned(&state, target, target_id, user.id).await?;
87
88 let rows = match target {
89 GalleryTarget::Item => db::gallery_images::list_for_item(&state.db, ItemId::from(target_id)).await?,
90 GalleryTarget::Project => db::gallery_images::list_for_project(&state.db, ProjectId::from(target_id)).await?,
91 };
92 let items: Vec<GalleryListItem> = rows
93 .into_iter()
94 .map(|g| GalleryListItem { id: g.id, image_url: g.image_url, alt: g.alt })
95 .collect();
96 Ok(Json(items))
97 }
98
99 // ---------------------------------------------------------------------------
100 // Presign
101 // ---------------------------------------------------------------------------
102
103 #[derive(Debug, Deserialize)]
104 pub struct GalleryPresignRequest {
105 pub target_type: String,
106 pub target_id: Uuid,
107 pub file_name: String,
108 pub content_type: String,
109 }
110
111 /// POST /api/gallery/presign — presign a gallery image upload.
112 #[tracing::instrument(skip_all, name = "storage::gallery_presign", fields(user_id = %user.id))]
113 pub(super) async fn gallery_presign(
114 State(state): State<AppState>,
115 AuthUser(user): AuthUser,
116 Json(req): Json<GalleryPresignRequest>,
117 ) -> Result<impl IntoResponse> {
118 user.check_not_suspended()?;
119 let s3 = state.require_s3()?;
120 let target = GalleryTarget::parse(&req.target_type)?;
121
122 let file_type = FileType::Cover;
123 S3Client::validate_content_type(file_type, &req.content_type)?;
124 S3Client::validate_extension(file_type, &req.file_name)?;
125
126 require_owned(&state, target, req.target_id, user.id).await?;
127
128 // Early per-entity cap check (authoritative re-check happens at confirm).
129 let count = match target {
130 GalleryTarget::Item => db::gallery_images::count_for_item(&state.db, ItemId::from(req.target_id)).await?,
131 GalleryTarget::Project => db::gallery_images::count_for_project(&state.db, ProjectId::from(req.target_id)).await?,
132 };
133 if count >= db::gallery_images::MAX_GALLERY_IMAGES {
134 return Err(AppError::BadRequest(format!(
135 "Gallery is full (max {} images)",
136 db::gallery_images::MAX_GALLERY_IMAGES
137 )));
138 }
139
140 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
141
142 // Per-image uuid keeps multiple gallery uploads from colliding.
143 let image_uuid = Uuid::new_v4();
144 let s3_key = match target {
145 GalleryTarget::Item => {
146 S3Client::generate_item_gallery_key(user.id, ItemId::from(req.target_id), image_uuid, &req.file_name)
147 }
148 GalleryTarget::Project => {
149 S3Client::generate_project_gallery_key(ProjectId::from(req.target_id), image_uuid, &req.file_name)
150 }
151 };
152
153 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
154
155 let expires_in = 3600;
156 let upload_url = s3
157 .presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None)
158 .await
159 .context("presign upload for gallery image")?;
160
161 Ok(Json(PresignUploadResponse {
162 upload_url,
163 s3_key,
164 expires_in,
165 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
166 max_file_bytes: None,
167 }))
168 }
169
170 // ---------------------------------------------------------------------------
171 // Confirm
172 // ---------------------------------------------------------------------------
173
174 #[derive(Debug, Deserialize)]
175 pub struct GalleryConfirmRequest {
176 pub target_type: String,
177 pub target_id: Uuid,
178 pub s3_key: String,
179 #[serde(default)]
180 pub alt: String,
181 }
182
183 #[derive(Debug, Serialize)]
184 pub struct GalleryConfirmResponse {
185 pub success: bool,
186 pub id: Uuid,
187 pub image_url: String,
188 pub alt: String,
189 }
190
191 /// POST /api/gallery/confirm — finalize a gallery image upload.
192 #[tracing::instrument(skip_all, name = "storage::gallery_confirm", fields(user_id = %user.id))]
193 pub(super) async fn gallery_confirm(
194 State(state): State<AppState>,
195 AuthUser(user): AuthUser,
196 Json(req): Json<GalleryConfirmRequest>,
197 ) -> Result<impl IntoResponse> {
198 user.check_not_suspended()?;
199 let s3 = state.require_s3()?;
200 let target = GalleryTarget::parse(&req.target_type)?;
201
202 require_owned(&state, target, req.target_id, user.id).await?;
203
204 // Key must live under this entity's gallery prefix (no cross-entity steal).
205 let expected_prefix = match target {
206 GalleryTarget::Item => format!("{}/{}/gallery/", user.id, req.target_id),
207 GalleryTarget::Project => format!("projects/{}/gallery/", req.target_id),
208 };
209 if !req.s3_key.starts_with(&expected_prefix) {
210 return Err(AppError::BadRequest("Invalid upload key".to_string()));
211 }
212
213 if !s3.object_exists(&req.s3_key).await? {
214 return Err(AppError::BadRequest(
215 "Upload not found. Please try uploading again.".to_string(),
216 ));
217 }
218
219 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
220 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
221 })?;
222 if file_size_bytes as u64 > FileType::Cover.max_size() {
223 s3.delete_object(&req.s3_key).await.ok();
224 return Err(AppError::BadRequest(format!(
225 "File exceeds maximum size of {} MB",
226 FileType::Cover.max_size() / (1024 * 1024)
227 )));
228 }
229
230 // Authoritative cap re-check (presign's was best-effort/UX).
231 let count = match target {
232 GalleryTarget::Item => db::gallery_images::count_for_item(&state.db, ItemId::from(req.target_id)).await?,
233 GalleryTarget::Project => db::gallery_images::count_for_project(&state.db, ProjectId::from(req.target_id)).await?,
234 };
235 if count >= db::gallery_images::MAX_GALLERY_IMAGES {
236 s3.delete_object(&req.s3_key).await.ok();
237 return Err(AppError::BadRequest(format!(
238 "Gallery is full (max {} images)",
239 db::gallery_images::MAX_GALLERY_IMAGES
240 )));
241 }
242
243 let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await {
244 Ok(max) => max,
245 Err(e) => {
246 s3.delete_object(&req.s3_key).await.ok();
247 return Err(e);
248 }
249 };
250
251 let image_url = storage::build_project_image_url(
252 s3.as_ref(),
253 state.config.cdn_base_url.as_deref(),
254 &req.s3_key,
255 )
256 .await?;
257
258 let alt = req.alt.trim().to_string();
259
260 // Advisory-lock key for serializing concurrent confirms on this gallery:
261 // class separates item vs project; obj folds the target UUID to i32 (a
262 // collision only over-serializes two unrelated galleries, never under-).
263 let gallery_lock_class: i32 = match target {
264 GalleryTarget::Item => 0,
265 GalleryTarget::Project => 1,
266 };
267 let gallery_lock_obj: i32 = {
268 let b = req.target_id.as_bytes();
269 i32::from_le_bytes([b[0], b[1], b[2], b[3]])
270 ^ i32::from_le_bytes([b[4], b[5], b[6], b[7]])
271 ^ i32::from_le_bytes([b[8], b[9], b[10], b[11]])
272 ^ i32::from_le_bytes([b[12], b[13], b[14], b[15]])
273 };
274
275 // Storage increment + row INSERT in ONE transaction (gallery is add-only, so
276 // a pure increment — no old-key replacement). A rollback restores the counter.
277 let committed: Result<Uuid> = async {
278 let mut tx = state.db.begin().await?;
279 // Serialize concurrent confirms for this gallery and re-count INSIDE the
280 // tx, so two inserts that both passed the best-effort pre-check above
281 // can't push it over MAX_GALLERY_IMAGES (Run #14 Storage LOW). A failure
282 // here routes through the orphan-enqueue cleanup below.
283 sqlx::query("SELECT pg_advisory_xact_lock($1, $2)")
284 .bind(gallery_lock_class)
285 .bind(gallery_lock_obj)
286 .execute(&mut *tx)
287 .await?;
288 let count_in_tx = match target {
289 GalleryTarget::Item => db::gallery_images::count_for_item(&mut *tx, ItemId::from(req.target_id)).await?,
290 GalleryTarget::Project => db::gallery_images::count_for_project(&mut *tx, ProjectId::from(req.target_id)).await?,
291 };
292 if count_in_tx >= db::gallery_images::MAX_GALLERY_IMAGES {
293 return Err(AppError::BadRequest(format!(
294 "Gallery is full (max {} images)",
295 db::gallery_images::MAX_GALLERY_IMAGES
296 )));
297 }
298 db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage).await?;
299 let id = match target {
300 GalleryTarget::Item => {
301 db::gallery_images::insert_for_item(
302 &mut *tx, ItemId::from(req.target_id), &req.s3_key, &image_url, &alt, file_size_bytes,
303 )
304 .await?
305 }
306 GalleryTarget::Project => {
307 db::gallery_images::insert_for_project(
308 &mut *tx, ProjectId::from(req.target_id), &req.s3_key, &image_url, &alt, file_size_bytes,
309 )
310 .await?
311 }
312 };
313 tx.commit().await?;
314 Ok(id)
315 }
316 .await;
317
318 let id = match committed {
319 Ok(id) => id,
320 Err(e) => {
321 super::enqueue_s3_orphan(&state.db, &req.s3_key, "gallery_image_insert_failed").await;
322 return Err(e);
323 }
324 };
325
326 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
327
328 // Scan enqueue AFTER the DB write commits (the chronic-ordering rule).
329 commit_upload(
330 &state,
331 CommitTarget::GalleryImage(id),
332 &req.s3_key,
333 FileType::Cover,
334 user.id,
335 file_size_bytes,
336 )
337 .await?;
338
339 bump_target_cache(&state, target, req.target_id).await;
340
341 Ok(Json(GalleryConfirmResponse { success: true, id, image_url, alt }))
342 }
343
344 // ---------------------------------------------------------------------------
345 // Delete
346 // ---------------------------------------------------------------------------
347
348 /// DELETE /api/gallery/image/{target_type}/{image_id} — remove one gallery image.
349 #[tracing::instrument(skip_all, name = "storage::gallery_delete", fields(user_id = %user.id, %target_type, %image_id))]
350 pub(super) async fn gallery_delete(
351 State(state): State<AppState>,
352 AuthUser(user): AuthUser,
353 Path((target_type, image_id)): Path<(String, Uuid)>,
354 ) -> Result<impl IntoResponse> {
355 user.check_not_suspended()?;
356 let target = GalleryTarget::parse(&target_type)?;
357
358 // Delete the row (ownership-scoped) + decrement storage in one tx; the row
359 // returns its s3_key + size so we never probe S3 for the decrement.
360 let deleted: Option<db::gallery_images::GalleryImage> = {
361 let mut tx = state.db.begin().await?;
362 let row = match target {
363 GalleryTarget::Item => db::gallery_images::delete_for_item(&mut *tx, image_id, user.id).await?,
364 GalleryTarget::Project => db::gallery_images::delete_for_project(&mut *tx, image_id, user.id).await?,
365 };
366 if let Some(ref r) = row {
367 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, r.file_size_bytes).await?;
368 tx.commit().await?;
369 }
370 // If row is None the tx drops (rolls back) untouched.
371 row
372 };
373
374 let Some(row) = deleted else {
375 return Err(AppError::NotFound);
376 };
377
378 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
379 &state.db,
380 &[(row.s3_key.clone(), "main".to_string())],
381 "gallery_image_delete",
382 )
383 .await
384 {
385 tracing::warn!(key = %row.s3_key, error = ?e, "failed to enqueue deleted gallery image for S3 deletion");
386 }
387
388 Ok(Json(serde_json::json!({ "success": true })))
389 }
390
391 // ---------------------------------------------------------------------------
392 // Reorder
393 // ---------------------------------------------------------------------------
394
395 #[derive(Debug, Deserialize)]
396 pub struct GalleryReorderRequest {
397 pub target_type: String,
398 pub target_id: Uuid,
399 pub ordered_ids: Vec<Uuid>,
400 }
401
402 /// POST /api/gallery/reorder — set gallery display order.
403 #[tracing::instrument(skip_all, name = "storage::gallery_reorder", fields(user_id = %user.id))]
404 pub(super) async fn gallery_reorder(
405 State(state): State<AppState>,
406 AuthUser(user): AuthUser,
407 Json(req): Json<GalleryReorderRequest>,
408 ) -> Result<impl IntoResponse> {
409 user.check_not_suspended()?;
410 let target = GalleryTarget::parse(&req.target_type)?;
411 require_owned(&state, target, req.target_id, user.id).await?;
412
413 match target {
414 GalleryTarget::Item => {
415 db::gallery_images::reorder_item(&state.db, ItemId::from(req.target_id), &req.ordered_ids).await?
416 }
417 GalleryTarget::Project => {
418 db::gallery_images::reorder_project(&state.db, ProjectId::from(req.target_id), &req.ordered_ids).await?
419 }
420 }
421
422 bump_target_cache(&state, target, req.target_id).await;
423 Ok(Json(serde_json::json!({ "success": true })))
424 }
425
426 /// Bump the public-page cache generation for the affected project (best-effort).
427 async fn bump_target_cache(state: &AppState, target: GalleryTarget, target_id: Uuid) {
428 let project_id = match target {
429 GalleryTarget::Project => Some(ProjectId::from(target_id)),
430 GalleryTarget::Item => db::items::get_item_by_id(&state.db, ItemId::from(target_id))
431 .await
432 .ok()
433 .flatten()
434 .map(|i| i.project_id),
435 };
436 if let Some(pid) = project_id
437 && let Err(e) = db::projects::bump_cache_generation(&state.db, pid).await
438 {
439 tracing::warn!(project_id = %pid, error = ?e, "failed to bump cache generation after gallery change");
440 }
441 }
442