Skip to main content

max / makenotwork

14.0 KB · 354 lines History Blame Raw
1 //! Presigned upload and confirm handlers for item content.
2
3 use axum::{
4 extract::State,
5 response::IntoResponse,
6 Json,
7 };
8 use serde::Deserialize;
9 use std::str::FromStr;
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ItemId},
14 error::{AppError, Result, ResultExt},
15 storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
16 AppState,
17 };
18
19 use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse};
20
21 /// JSON input for requesting a presigned S3 upload URL.
22 ///
23 /// `file_size_bytes` is optional for compatibility with older clients that
24 /// don't know the size ahead of time, but when supplied it is signed into
25 /// the presigned URL's `Content-Length` and S3 will reject any PUT whose
26 /// actual body length differs — protocol-level enforcement of the per-file
27 /// cap, no bandwidth wasted on oversized uploads that the confirm step
28 /// would have rejected anyway.
29 #[derive(Debug, Deserialize)]
30 pub struct PresignUploadRequest {
31 pub item_id: ItemId,
32 pub file_type: String,
33 pub file_name: String,
34 pub content_type: String,
35 #[serde(default)]
36 pub file_size_bytes: Option<i64>,
37 }
38
39 /// JSON input for confirming a completed S3 upload.
40 #[derive(Debug, Deserialize)]
41 pub struct ConfirmUploadRequest {
42 pub item_id: ItemId,
43 pub file_type: String,
44 pub s3_key: String,
45 }
46
47 /// Generate a presigned URL for uploading a file to S3
48 ///
49 /// POST /api/upload/presign
50 ///
51 /// Requires authentication. User must own the item.
52 #[tracing::instrument(skip_all, name = "storage::presign_upload", fields(user_id = %user.id))]
53 pub(super) async fn presign_upload(
54 State(state): State<AppState>,
55 AuthUser(user): AuthUser,
56 Json(req): Json<PresignUploadRequest>,
57 ) -> Result<impl IntoResponse> {
58 user.check_not_suspended()?;
59 // Check if S3 is configured
60 let s3 = state.require_s3()?;
61
62 // Parse file type
63 let file_type = FileType::from_str(&req.file_type)
64 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
65
66 // Validate content type
67 S3Client::validate_content_type(file_type, &req.content_type)?;
68
69 // Validate file extension
70 S3Client::validate_extension(file_type, &req.file_name)?;
71
72 // Verify user owns the item
73 let owner = db::items::get_item_owner(&state.db, req.item_id)
74 .await?
75 .ok_or(AppError::NotFound)?;
76
77 if owner != user.id {
78 return Err(AppError::Forbidden);
79 }
80
81 // Early quota check (reject before generating presigned URL)
82 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
83
84 // Get the effective max file size for client-side pre-validation
85 let max_file_bytes = db::creator_tiers::get_effective_max_file_bytes(&state.db, user.id, file_type).await?;
86
87 // If the client declared the file size, validate it before signing —
88 // both against the static per-type cap and the tier-effective cap. We
89 // bind it as Content-Length so S3 rejects oversized PUTs at the protocol
90 // level. Clients omitting `file_size_bytes` fall back to the old behavior
91 // (no protocol-level enforcement; confirm step still validates).
92 if let Some(size) = req.file_size_bytes {
93 if size <= 0 {
94 return Err(AppError::BadRequest("file_size_bytes must be positive".to_string()));
95 }
96 if size as u64 > file_type.max_size() {
97 let limit_mb = file_type.max_size() / (1024 * 1024);
98 let file_mb = size as u64 / (1024 * 1024);
99 return Err(AppError::FileTooLarge(format!(
100 "File is {} MB but the maximum for {} files is {} MB.",
101 file_mb, file_type.as_str(), limit_mb
102 )));
103 }
104 if let Some(tier_cap) = max_file_bytes
105 && (size as u64) > tier_cap
106 {
107 let limit_mb = tier_cap / (1024 * 1024);
108 return Err(AppError::FileTooLarge(format!(
109 "File exceeds your tier's per-file limit of {} MB.",
110 limit_mb
111 )));
112 }
113 }
114
115 // Generate S3 key
116 let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name);
117
118 // Track the pending upload so the reaper can clean it up if never confirmed
119 db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
120
121 // Generate presigned upload URL with immutable cache headers
122 let expires_in = 3600; // 1 hour
123 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), req.file_size_bytes)
124 .await
125 .context("presign upload for item content")?;
126
127 Ok(Json(PresignUploadResponse {
128 upload_url,
129 s3_key,
130 expires_in,
131 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
132 max_file_bytes,
133 }))
134 }
135
136 /// Confirm that an upload has completed and update the database
137 ///
138 /// POST /api/upload/confirm
139 ///
140 /// Requires authentication. User must own the item.
141 #[tracing::instrument(skip_all, name = "storage::confirm_upload", fields(user_id = %user.id))]
142 pub(super) async fn confirm_upload(
143 State(state): State<AppState>,
144 AuthUser(user): AuthUser,
145 Json(req): Json<ConfirmUploadRequest>,
146 ) -> Result<impl IntoResponse> {
147 user.check_not_suspended()?;
148 // Check if S3 is configured
149 let s3 = state.require_s3()?;
150
151 // Parse file type
152 let file_type = FileType::from_str(&req.file_type)
153 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
154
155 // Verify user owns the item
156 let owner = db::items::get_item_owner(&state.db, req.item_id)
157 .await?
158 .ok_or(AppError::NotFound)?;
159
160 if owner != user.id {
161 return Err(AppError::Forbidden);
162 }
163
164 // Validate S3 key belongs to this user + item (prevent cross-user file reference)
165 let expected_prefix = format!("{}/{}/", user.id, req.item_id);
166 if !req.s3_key.starts_with(&expected_prefix) {
167 return Err(AppError::BadRequest(
168 "Invalid upload key".to_string(),
169 ));
170 }
171
172 // Verify the object exists in S3
173 if !s3.object_exists(&req.s3_key).await? {
174 return Err(AppError::BadRequest(
175 "Upload not found. Please try uploading again.".to_string(),
176 ));
177 }
178
179 // Enforce file size limit (static per-type limit)
180 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
181 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
182 })?;
183 if file_size_bytes as u64 > file_type.max_size() {
184 s3.delete_object(&req.s3_key).await.ok();
185 let limit_mb = file_type.max_size() / (1024 * 1024);
186 let file_mb = file_size_bytes as u64 / (1024 * 1024);
187 return Err(AppError::FileTooLarge(format!(
188 "File is {} MB but the maximum for {} files is {} MB.",
189 file_mb, file_type.as_str(), limit_mb
190 )));
191 }
192
193 // Enforce tier-based limits (per-file + storage cap)
194 let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, file_type, file_size_bytes).await {
195 Ok(max) => max,
196 Err(e) => {
197 s3.delete_object(&req.s3_key).await.ok();
198 return Err(e);
199 }
200 };
201
202 // Resolve, from the single exhaustive declaration on `FileType`, how this
203 // type is confirmed on an `items` row — and reject types that belong to a
204 // dedicated route BEFORE any scan enqueue or scan_status flip. (A misrouted
205 // but valid item_id would otherwise flip scan_status to Pending, block every
206 // fan's download, and leak a scan_jobs row for an S3 key we're about to
207 // delete.) Cover is rejected here now: it also needs `cover_image_url`,
208 // which only /api/items/image/confirm writes — the old generic two-column
209 // path left it NULL and rendered an invisible cover (Run #13 SERIOUS).
210 let (s3_col, size_col) = match file_type.generic_item_confirm() {
211 crate::storage::GenericItemConfirm::Columns { s3_key, size } => (s3_key, size),
212 crate::storage::GenericItemConfirm::UseRoute(route) => {
213 s3.delete_object(&req.s3_key).await.ok();
214 return Err(AppError::BadRequest(format!(
215 "This file type isn't confirmed here — use {route}."
216 )));
217 }
218 };
219
220 // Idempotency + replace detection. We read the item ONCE here and reuse its
221 // project_id for the cache bump below (previously a second get_item_by_id).
222 let mut old_s3_key: Option<String> = None;
223 let mut replaced_old_size: i64 = 0;
224 let mut item_project_id: Option<db::ProjectId> = None;
225 if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? {
226 item_project_id = Some(item.project_id);
227 let existing_key = match file_type {
228 FileType::Audio => item.audio_s3_key.as_deref(),
229 FileType::Cover => item.cover_s3_key.as_deref(),
230 FileType::Video => item.video_s3_key.as_deref(),
231 _ => None,
232 };
233 if existing_key == Some(&req.s3_key) {
234 // Idempotent re-confirm: the entity already references this s3_key.
235 // Still clear the pending_uploads row — otherwise the orphan reaper
236 // will fire 24h later and delete the live S3 object out from under
237 // a perfectly happy DB row (Run #7 HIGH-1).
238 if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await {
239 tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm");
240 }
241 return Ok(Json(ConfirmUploadResponse { success: true, pending_review: None }));
242 }
243 if let Some(old_key) = existing_key {
244 old_s3_key = Some(old_key.to_string());
245 replaced_old_size = match file_type {
246 FileType::Audio => item.audio_file_size_bytes.unwrap_or(0),
247 FileType::Cover => item.cover_file_size_bytes.unwrap_or(0),
248 FileType::Video => item.video_file_size_bytes.unwrap_or(0),
249 _ => 0,
250 };
251 }
252 }
253 let is_replace = old_s3_key.is_some();
254
255 let update_sql = format!(
256 "UPDATE items SET {} = $2, {} = $3, updated_at = NOW() WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $4)",
257 s3_col, size_col,
258 );
259
260 // Storage credit + item UPDATE in ONE transaction. A rollback restores both,
261 // so a mid-write failure can't leave the storage counter inflated against a
262 // row that never got the key (the previous compensating-action path with
263 // swallowed `.ok()` errors). `commit_upload` (scan enqueue + scan_status
264 // flip) stays AFTER the commit — that ordering is the blessed path; see
265 // `routes/storage/mod.rs::commit_upload`. `Ok(0)` signals the item vanished
266 // between the owner check and the UPDATE (tx rolled back, nothing charged).
267 let tx_result: Result<u64> = async {
268 let mut tx = state.db.begin().await?;
269 db::creator_tiers::try_apply_storage_on(
270 &mut tx, user.id, is_replace.then_some(replaced_old_size), file_size_bytes, max_storage,
271 ).await?;
272 let res = sqlx::query(&update_sql)
273 .bind(req.item_id)
274 .bind(&req.s3_key)
275 .bind(file_size_bytes)
276 .bind(user.id)
277 .execute(&mut *tx)
278 .await?;
279 if res.rows_affected() == 0 {
280 // Leave the tx uncommitted — drop rolls it back, undoing the storage
281 // change with no manual compensation.
282 return Ok(0);
283 }
284 tx.commit().await?;
285 Ok(res.rows_affected())
286 }
287 .await;
288
289 match tx_result {
290 Err(e) => {
291 // tx rolled back — storage counter unchanged. Just clean up S3.
292 s3.delete_object(&req.s3_key).await.ok();
293 return Err(e);
294 }
295 Ok(0) => {
296 // Item was deleted (or transferred out from under the ownership
297 // filter) between our earlier `get_item_owner` check and this UPDATE.
298 // The tx rolled back, so nothing was charged; route the now-unreferenced
299 // object through the orphan queue so the reaper still cleans it.
300 super::enqueue_s3_orphan(&state.db, &req.s3_key, "item_upload_target_missing").await;
301 return Err(AppError::BadRequest(
302 "Item was modified concurrently. Please try uploading again.".to_string(),
303 ));
304 }
305 Ok(_) => {}
306 }
307
308 // Scan enqueue + scan_status flip happens AFTER the DB UPDATE commits via
309 // the shared `commit_upload` helper, which is the only blessed path for
310 // this ordering. See `routes/storage/mod.rs::commit_upload` for the bug
311 // shapes this prevents.
312 let scan_status = commit_upload(
313 &state,
314 CommitTarget::Item(req.item_id),
315 &req.s3_key,
316 file_type,
317 user.id,
318 file_size_bytes,
319 ).await?;
320
321 // Delete the old S3 object now that the replacement is committed. Route
322 // through the orphan queue so a transient S3 failure here doesn't leak the
323 // object forever — the worker retries until the delete succeeds (or 404s).
324 if let Some(old_key) = &old_s3_key {
325 super::enqueue_s3_orphan(&state.db, old_key, "item_upload_replace").await;
326 }
327
328 // Clear the pending upload record now that the upload is confirmed
329 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?;
330
331 // Bump project cache generation so dashboard tabs reflect the new upload.
332 // Reuses the project_id read during idempotency above (no second fetch).
333 if let Some(project_id) = item_project_id
334 && let Err(e) = db::projects::bump_cache_generation(&state.db, project_id).await
335 {
336 tracing::warn!(%project_id, error = ?e, "failed to bump cache generation after upload");
337 }
338
339 tracing::info!(
340 "Upload confirmed: item={}, type={:?}, key={}, size={}",
341 req.item_id,
342 file_type,
343 req.s3_key,
344 file_size_bytes
345 );
346
347 let pending_review = if scan_status == db::FileScanStatus::HeldForReview {
348 Some(true)
349 } else {
350 None
351 };
352 Ok(Json(ConfirmUploadResponse { success: true, pending_review }))
353 }
354