Skip to main content

max / makenotwork

26.2 KB · 667 lines History Blame Raw
1 //! Storage API routes for S3 file uploads and streaming.
2 //!
3 //! Two seals live here and every storage handler is written against them.
4 //! [`enqueue_s3_orphan`] is the only route-side path to an S3 delete; the
5 //! backend delete methods want an `S3DeleteAuthority` a handler cannot mint.
6 //! [`commit_upload`] is the only entry point to the scan pipeline, so the
7 //! scan-after-DB-commit ordering holds and `enqueue_scan_for` stays private.
8
9 mod downloads;
10 mod gallery;
11 mod images;
12 pub(crate) mod media;
13 mod uploads;
14 mod versions;
15
16 use std::sync::Arc;
17
18 use axum::routing::get;
19 use serde::Serialize;
20 use sqlx::PgPool;
21 use tower_governor::GovernorLayer;
22 use uuid::Uuid;
23
24 use crate::{
25 AppState, AppStorage,
26 config::Config,
27 constants,
28 csrf::{CsrfRouter, delete_csrf, post_csrf},
29 db,
30 db::scan_jobs::ScanTargetKind,
31 error::{AppError, Result},
32 scanning::ScanPipeline,
33 storage::FileType,
34 };
35
36 /// Validate a client-declared upload size before it is signed into the
37 /// presigned URL's `Content-Length`. Checks the static per-type cap and the
38 /// tier-effective cap. Centralized so every presign handler (item, version,
39 /// media, gallery) enforces the same bounds instead of re-deriving them, the
40 /// item path used to be the only one that bound the cap, leaving the largest
41 /// file classes (version downloads, media video) unenforced at the protocol
42 /// level. `None` size = the client didn't declare one; the confirm-time HEAD
43 /// still bounds it.
44 pub(crate) fn validate_declared_upload_size(
45 size: Option<i64>,
46 file_type: FileType,
47 max_file_bytes: Option<u64>,
48 ) -> Result<()> {
49 validate_declared_upload_size_limits(size, file_type, max_file_bytes)?;
50 let Some(size) = size else {
51 return Ok(());
52 };
53 // Browser transport ceiling: every caller of THIS function issues a single
54 // presigned PUT, which a tab cannot resume. The tier cap can be much higher
55 // (BigFiles/Everything allow 20 GB), those files upload through the
56 // CLI/desktop clients, which chunk and resume, so point a too-big browser
57 // upload there instead of handing out a presigned URL for a transfer that
58 // will strand the person if it drops.
59 // Deliberately NOT in `validate_declared_upload_size_limits`: it is a
60 // property of the one-shot transport, not of the file, and the multipart
61 // path is exactly what this message points people toward.
62 if size as u64 > constants::BROWSER_UPLOAD_MAX_BYTES {
63 let limit_gb = constants::BROWSER_UPLOAD_MAX_BYTES / (1024 * 1024 * 1024);
64 return Err(AppError::FileTooLarge(format!(
65 "Files larger than {limit_gb} GB must be uploaded with the makenot.work CLI or desktop app."
66 )));
67 }
68 Ok(())
69 }
70
71 /// The size limits that hold for *any* transport: positive, the per-file-type
72 /// cap, and the tier's per-file cap. The single-PUT ceiling is excluded, so the
73 /// multipart (CLI/desktop) path can accept files above it while still enforcing
74 /// every limit that describes the file rather than how it is carried.
75 pub(crate) fn validate_declared_upload_size_limits(
76 size: Option<i64>,
77 file_type: FileType,
78 max_file_bytes: Option<u64>,
79 ) -> Result<()> {
80 let Some(size) = size else {
81 return Ok(());
82 };
83 if size <= 0 {
84 return Err(AppError::BadRequest(
85 "file_size_bytes must be positive".to_string(),
86 ));
87 }
88 if size as u64 > file_type.max_size() {
89 let limit_mb = file_type.max_size() / (1024 * 1024);
90 let file_mb = size as u64 / (1024 * 1024);
91 return Err(AppError::FileTooLarge(format!(
92 "File is {} MB but the maximum for {} files is {} MB.",
93 file_mb,
94 file_type.as_str(),
95 limit_mb
96 )));
97 }
98 if let Some(tier_cap) = max_file_bytes
99 && (size as u64) > tier_cap
100 {
101 let limit_mb = tier_cap / (1024 * 1024);
102 return Err(AppError::FileTooLarge(format!(
103 "File exceeds your tier's per-file limit of {limit_mb} MB."
104 )));
105 }
106 Ok(())
107 }
108
109 /// Enqueue an orphaned S3 key for the pending-deletion worker.
110 ///
111 /// This is the ONLY way a route handler may cause an S3 object to be deleted.
112 /// Direct deletion is sealed off, the `StorageBackend` delete methods require
113 /// an [`S3DeleteAuthority`](crate::storage::S3DeleteAuthority) that handlers
114 /// cannot mint, so every handler-side delete, whether a
115 /// post-credit failure (storage credited / row inserted / old object being
116 /// replaced) or a pre-credit rejection (size cap, type-mismatch, tier check),
117 /// routes through here. The queue worker applies the `is_s3_key_live` guard
118 /// before deleting, so enqueuing a key a live row still references is safe (it
119 /// is skipped), and a transient failure is retried rather than leaking.
120 pub(crate) async fn enqueue_s3_orphan(
121 pool: &sqlx::PgPool,
122 s3_key: &str,
123 bucket: crate::storage::S3Bucket,
124 source: &'static str,
125 ) {
126 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
127 pool,
128 &[(s3_key.to_string(), bucket.as_str().to_string())],
129 source,
130 )
131 .await
132 {
133 tracing::warn!(error = ?e, key = %s3_key, bucket = %bucket.as_str(), source = %source, "failed to enqueue orphan S3 key");
134 }
135 }
136
137 /// Register S3 upload and streaming routes.
138 ///
139 /// Upload routes (presign + confirm) are rate limited per IP (see `constants::UPLOAD_RATE_LIMIT_*`).
140 /// Stream/download endpoints are unlimited (presigned URLs already expire in 1 hour).
141 pub fn storage_routes() -> CsrfRouter<AppState> {
142 let upload_rate_limit = crate::helpers::rate_limiter_ms(
143 constants::UPLOAD_RATE_LIMIT_MS,
144 constants::UPLOAD_RATE_LIMIT_BURST,
145 );
146
147 let upload_routes = CsrfRouter::new()
148 .route("/api/upload/presign", post_csrf(uploads::presign_upload))
149 .route("/api/upload/confirm", post_csrf(uploads::confirm_upload))
150 .route(
151 "/api/versions/{version_id}/upload/presign",
152 post_csrf(versions::version_presign_upload),
153 )
154 .route(
155 "/api/versions/{version_id}/upload/confirm",
156 post_csrf(versions::version_confirm_upload),
157 )
158 .route(
159 "/api/projects/image/presign",
160 post_csrf(images::project_image_presign),
161 )
162 .route(
163 "/api/projects/image/confirm",
164 post_csrf(images::project_image_confirm),
165 )
166 .route(
167 "/api/items/image/presign",
168 post_csrf(images::item_image_presign),
169 )
170 .route(
171 "/api/items/image/confirm",
172 post_csrf(images::item_image_confirm),
173 )
174 .route("/api/gallery/presign", post_csrf(gallery::gallery_presign))
175 .route("/api/gallery/confirm", post_csrf(gallery::gallery_confirm))
176 .route("/api/gallery/reorder", post_csrf(gallery::gallery_reorder))
177 .route_get(
178 "/api/gallery/list/{target_type}/{target_id}",
179 get(gallery::gallery_list),
180 )
181 .route(
182 "/api/gallery/image/{target_type}/{image_id}",
183 delete_csrf(gallery::gallery_delete),
184 )
185 .route("/api/media/presign", post_csrf(media::media_presign))
186 .route("/api/media/confirm", post_csrf(media::media_confirm))
187 .route_get("/api/media", get(media::media_list))
188 .route_get("/api/media/folders", get(media::media_folders))
189 // The described picker: markup rather than JSON, and reads rather than
190 // writes, so it sits with the media data it draws and under the same
191 // upload rate limit as the rest of this router.
192 .route_get("/media/picker", get(media::media_picker))
193 .route_get("/media/picker/grid", get(media::media_picker_grid))
194 .route_get("/media/picker/close", get(media::media_picker_close))
195 .route("/api/media/{id}", delete_csrf(media::media_delete))
196 .route_layer(GovernorLayer::new(upload_rate_limit));
197
198 let stream_rate_limit = crate::helpers::rate_limiter_ms(
199 constants::STREAM_RATE_LIMIT_MS,
200 constants::STREAM_RATE_LIMIT_BURST,
201 );
202
203 let stream_routes = CsrfRouter::new()
204 .route_get("/api/stream/{item_id}", get(downloads::stream_url))
205 .route_get(
206 "/api/versions/{version_id}/download",
207 get(downloads::version_download),
208 )
209 .route_layer(GovernorLayer::new(stream_rate_limit));
210
211 upload_routes.merge(stream_routes)
212 }
213
214 // Shared Request/Response Types
215
216 /// JSON response containing the presigned upload URL and S3 key.
217 #[derive(Debug, Serialize)]
218 pub struct PresignUploadResponse {
219 pub upload_url: String,
220 pub s3_key: String,
221 pub expires_in: u64,
222 /// Cache-Control header the client must send with the S3 PUT (part of the presigned signature).
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub cache_control: Option<String>,
225 /// Maximum file size in bytes for this upload (for client-side pre-validation).
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub max_file_bytes: Option<u64>,
228 }
229
230 /// JSON response confirming a successful upload.
231 #[derive(Debug, Serialize)]
232 pub struct ConfirmUploadResponse {
233 pub success: bool,
234 /// When true, the file was uploaded but is pending manual review before
235 /// it becomes available to fans. The creator should see a "pending review"
236 /// indicator instead of assuming the file is live.
237 #[serde(skip_serializing_if = "Option::is_none")]
238 pub pending_review: Option<bool>,
239 }
240
241 // Helpers
242
243 /// Discriminates which entity an upload commit applies to, and carries the
244 /// per-target ID and the corresponding scan_status update.
245 ///
246 /// Construct one of these in your handler AFTER the entity's DB write has
247 /// committed, then pass it to [`commit_upload`]. Order matters, see
248 /// [`commit_upload`] docs.
249 pub(crate) enum CommitTarget {
250 /// An item (Audio/Cover/Video s3_key column on `items`).
251 Item(db::ItemId),
252 /// A version (`versions` table).
253 Version(db::VersionId),
254 /// A media library file (`media_files` table).
255 Media(db::MediaFileId),
256 /// A project cover image (`projects.cover_image_url`/`cover_s3_key`). Gated
257 /// by the CDN-image `cover_scan_status` column (migration 162): the worker
258 /// stamps it clean/held keyed on s3_key, and on quarantine purges the row.
259 ProjectImage(db::ProjectId),
260 /// An item cover image (`items.cover_s3_key`/`cover_image_url`), CDN-served
261 /// with no per-request gate. It is gated by the separate `cover_scan_status`
262 /// column (migration 162), NOT `items.scan_status` (which gates the
263 /// audio/video track, flipping that would take the published track offline).
264 /// The worker stamps `cover_scan_status` clean/held and, on quarantine, NULLs
265 /// the cover columns.
266 ItemImage(db::ItemId),
267 /// A gallery image row (`item_images`/`project_images`), gated by the row's
268 /// `scan_status` column (migration 162). The worker stamps it clean/held by
269 /// s3_key and purges on quarantine. Carries the gallery row's own id (for log
270 /// correlation only; unused by the worker, which acts on the s3_key).
271 GalleryImage(Uuid),
272 /// A content insertion clip (`content_insertions`), gated by its
273 /// `scan_status` column (migration 162); worker stamps clean/held by key.
274 ContentInsertion(db::ContentInsertionId),
275 }
276
277 impl CommitTarget {
278 fn kind(&self) -> ScanTargetKind {
279 match self {
280 CommitTarget::Item(_) => ScanTargetKind::Item,
281 CommitTarget::ItemImage(_) => ScanTargetKind::ItemImage,
282 CommitTarget::Version(_) => ScanTargetKind::Version,
283 CommitTarget::Media(_) => ScanTargetKind::Media,
284 CommitTarget::ProjectImage(_) => ScanTargetKind::ProjectImage,
285 CommitTarget::GalleryImage(_) => ScanTargetKind::GalleryImage,
286 CommitTarget::ContentInsertion(_) => ScanTargetKind::ContentInsertion,
287 }
288 }
289
290 fn target_uuid(&self) -> Uuid {
291 match self {
292 CommitTarget::Item(id) | CommitTarget::ItemImage(id) => (*id).into(),
293 CommitTarget::Version(id) => (*id).into(),
294 CommitTarget::Media(id) => (*id).into(),
295 CommitTarget::ProjectImage(id) => (*id).into(),
296 CommitTarget::GalleryImage(id) => *id,
297 CommitTarget::ContentInsertion(id) => (*id).into(),
298 }
299 }
300 }
301
302 /// Enqueue a scan job and write the resulting status onto the target entity.
303 ///
304 /// **Call this AFTER the DB write that commits the upload has succeeded.**
305 /// Calling it earlier produces three known bug shapes, chronic across four
306 /// audit runs, which is why the lower-level pieces (`enqueue_scan_for`,
307 /// `update_*_scan_status`) are gated behind this single entry point:
308 ///
309 /// 1. A handler that early-returns (idempotent re-confirm, route mismatch,
310 /// quota rejection) leaks a `scan_jobs` row and flips a Clean status back
311 /// to Pending, blocking every fan's download until a rescan.
312 /// 2. A failed DB write leaves a dangling scan_jobs row pointing at an S3
313 /// key that's about to be deleted.
314 /// 3. The worker can race the still-uncommitted entity row.
315 ///
316 /// Use [`CommitTarget`] to bind the target id + per-target status updater.
317 /// The function returns the `FileScanStatus` that was written (callers use
318 /// this to populate the `pending_review` field of `ConfirmUploadResponse`).
319 #[tracing::instrument(
320 skip_all,
321 name = "storage::commit_upload",
322 fields(kind = ?target.kind(), target_id = %target.target_uuid(), %user_id, file_size_bytes, scan_status = tracing::field::Empty)
323 )]
324 pub(crate) async fn commit_upload(
325 db: &PgPool,
326 scanner: Option<&Arc<ScanPipeline>>,
327 target: CommitTarget,
328 s3_key: &str,
329 file_type: FileType,
330 user_id: db::UserId,
331 file_size_bytes: i64,
332 ) -> Result<db::FileScanStatus> {
333 let scan_status = enqueue_scan_for(
334 db,
335 scanner,
336 target.kind(),
337 target.target_uuid(),
338 s3_key,
339 file_type,
340 user_id,
341 file_size_bytes,
342 )
343 .await?;
344 match target {
345 CommitTarget::Item(id) => {
346 db::scanning::update_item_scan_status(db, id, scan_status).await?;
347 }
348 CommitTarget::Version(id) => {
349 db::scanning::update_version_scan_status(db, id, scan_status).await?;
350 }
351 CommitTarget::Media(id) => {
352 db::scanning::update_media_file_scan_status(db, id, scan_status).await?;
353 }
354 CommitTarget::ItemImage(_)
355 | CommitTarget::ProjectImage(_)
356 | CommitTarget::GalleryImage(_)
357 | CommitTarget::ContentInsertion(_) => {
358 // Nothing to flip here at commit time: these CDN-served image kinds
359 // carry their own per-row scan gate (`cover_scan_status` on
360 // items/projects; `scan_status` on item_images/project_images/
361 // content_insertions, migration 162), which defaults to 'pending' so
362 // the row stays hidden until scanned. The worker stamps it clean/held
363 // keyed on s3_key when the scan completes (fail-closed held gate), and
364 // on quarantine purges the row / NULLs the cover columns. We must NOT
365 // flip `items.scan_status` here, that gates the audio/video track, and
366 // a cover re-scan would take the published track offline (Run #20).
367 }
368 }
369 tracing::Span::current().record("scan_status", tracing::field::debug(&scan_status));
370 Ok(scan_status)
371 }
372
373 /// Admin-rescan entry point. The entity already exists; we just need to
374 /// re-run the scan pipeline against its existing `s3_key`. Enqueues the
375 /// scan job then flips the per-row `scan_status` to Pending in the same
376 /// order `commit_upload` uses for first-scan, so admin handlers can't
377 /// invert it (the chronic disease the seal was built to prevent).
378 #[tracing::instrument(
379 skip_all,
380 name = "storage::commit_rescan",
381 fields(kind = ?target.kind(), target_id = %target.target_uuid(), %user_id, file_size_bytes)
382 )]
383 pub(crate) async fn commit_rescan(
384 db: &PgPool,
385 scanner: Option<&Arc<ScanPipeline>>,
386 target: CommitTarget,
387 s3_key: &str,
388 file_type: FileType,
389 user_id: db::UserId,
390 file_size_bytes: i64,
391 ) -> Result<db::FileScanStatus> {
392 enqueue_scan_for(
393 db,
394 scanner,
395 target.kind(),
396 target.target_uuid(),
397 s3_key,
398 file_type,
399 user_id,
400 file_size_bytes,
401 )
402 .await?;
403 let pending = db::FileScanStatus::Pending;
404 match target {
405 CommitTarget::Item(id) => {
406 db::scanning::update_item_scan_status(db, id, pending).await?;
407 }
408 CommitTarget::Version(id) => {
409 db::scanning::update_version_scan_status(db, id, pending).await?;
410 }
411 CommitTarget::Media(id) => {
412 db::scanning::update_media_file_scan_status(db, id, pending).await?;
413 }
414 CommitTarget::ItemImage(_)
415 | CommitTarget::ProjectImage(_)
416 | CommitTarget::GalleryImage(_)
417 | CommitTarget::ContentInsertion(_) => {
418 // Nothing to flip here: these kinds carry their own per-row gate
419 // (see `CommitTarget` and the matching arm in `commit_upload`). An
420 // `ItemImage` rescan must not flip `items.scan_status`, that gates
421 // the audio/video, not the cover.
422 }
423 }
424 Ok(pending)
425 }
426
427 /// Admin approve-held: promote an item's held gated files (audio/video) to their
428 /// content keys, then mark the item Clean. A held file sits at its unserved
429 /// staging key (the scan didn't promote it), so approving it must run the SAME
430 /// copy-then-repoint the scan worker's Clean path runs, otherwise the item is
431 /// marked Clean while still pointing at a staging object that is about to be
432 /// reaped. Factored here (beside `commit_rescan`) so the worker and admin promote
433 /// can't diverge (the anti-drift discipline the scan-ordering seal uses).
434 ///
435 /// Idempotent: a file already at a `{owner}/c/...` content key is skipped, so a
436 /// bulk approve over a mixed set (some already promoted) is safe.
437 #[tracing::instrument(skip_all, name = "storage::commit_promote_item", fields(%item_id))]
438 pub(crate) async fn commit_promote_item(
439 db: &PgPool,
440 storage: &AppStorage,
441 config: &Config,
442 item_id: db::ItemId,
443 ) -> Result<()> {
444 let item = db::items::get_item_by_id(db, item_id)
445 .await?
446 .ok_or(AppError::NotFound)?;
447 let owner = db::items::get_item_owner(db, item_id)
448 .await?
449 .ok_or(AppError::NotFound)?;
450 // Only the files still at a staging key need a copy; skip a file that is
451 // absent or already content-keyed (bulk approve over a mixed set). Scan
452 // results are recorded against the staging key, so a hash lookup on a content
453 // key would miss, hence the staging check gates everything, including the
454 // `require_s3` below (a held row with nothing to promote, e.g. no S3 backend
455 // configured, still marks Clean without needing storage).
456 let staged: Vec<(String, FileType)> = [
457 (item.audio_s3_key.clone(), FileType::Audio),
458 (item.video_s3_key.clone(), FileType::Video),
459 ]
460 .into_iter()
461 .filter_map(|(key, ft)| match key {
462 Some(k) if k.starts_with("staging/") => Some((k, ft)),
463 _ => None,
464 })
465 .collect();
466 if !staged.is_empty() {
467 let s3 = storage.require_s3()?;
468 for (staging_key, file_type) in staged {
469 let sha256 = db::scanning::latest_sha256_by_key(db, &staging_key)
470 .await?
471 .ok_or_else(|| {
472 AppError::Storage(format!(
473 "cannot approve item {item_id}: no scan hash recorded for {staging_key}"
474 ))
475 })?;
476 crate::scanning::promote_staging_to_content(
477 db,
478 s3.as_ref(),
479 // Item audio is a gated Main-bucket kind; the public backend is
480 // unused here but passed for signature consistency.
481 storage.public_s3.as_deref(),
482 &config.cdn_base_url,
483 ScanTargetKind::Item,
484 file_type,
485 item_id.into(),
486 owner,
487 &staging_key,
488 &sha256,
489 crate::storage::S3Bucket::Main,
490 )
491 .await?;
492 }
493 }
494 // `promote_gated` already set `scan_status = 'clean'` per promoted file; this
495 // also covers the no-staged-file case (all already content-keyed) so the
496 // admin action still lands the item Clean.
497 db::scanning::update_item_scan_status(db, item_id, db::FileScanStatus::Clean).await?;
498 Ok(())
499 }
500
501 /// Admin approve-held: promote a version's held download file to its content key,
502 /// then mark the version Clean. See [`commit_promote_item`] for why the copy must
503 /// happen at approve time and why it is factored here.
504 #[tracing::instrument(skip_all, name = "storage::commit_promote_version", fields(%version_id))]
505 pub(crate) async fn commit_promote_version(
506 db: &PgPool,
507 storage: &AppStorage,
508 config: &Config,
509 version_id: db::VersionId,
510 ) -> Result<()> {
511 let version = db::versions::get_version_by_id(db, version_id)
512 .await?
513 .ok_or(AppError::NotFound)?;
514 let owner = db::items::get_item_owner(db, version.item_id)
515 .await?
516 .ok_or(AppError::NotFound)?;
517 // `require_s3` only when there is a staging file to copy, a held row with no
518 // staging download (or an already content-keyed one) still marks Clean with no
519 // storage backend needed.
520 if let Some(staging_key) = version.s3_key.clone()
521 && staging_key.starts_with("staging/")
522 {
523 let s3 = storage.require_s3()?;
524 let sha256 = db::scanning::latest_sha256_by_key(db, &staging_key)
525 .await?
526 .ok_or_else(|| {
527 AppError::Storage(format!(
528 "cannot approve version {version_id}: no scan hash recorded for {staging_key}"
529 ))
530 })?;
531 crate::scanning::promote_staging_to_content(
532 db,
533 s3.as_ref(),
534 // Version downloads are a gated Main-bucket kind; public backend unused.
535 storage.public_s3.as_deref(),
536 &config.cdn_base_url,
537 ScanTargetKind::Version,
538 FileType::Download,
539 version_id.into(),
540 owner,
541 &staging_key,
542 &sha256,
543 crate::storage::S3Bucket::Main,
544 )
545 .await?;
546 }
547 db::scanning::update_version_scan_status(db, version_id, db::FileScanStatus::Clean).await?;
548 Ok(())
549 }
550
551 /// Enqueue an async scan job for an uploaded file and return the initial
552 /// `scan_status` to write onto the target entity.
553 ///
554 /// **Storage handlers do not call this directly**, they use [`commit_upload`]
555 /// so the ordering invariant (scan-after-DB-commit) cannot be inverted by a
556 /// future sibling handler. The function is private to `routes::storage` and has
557 /// exactly two callers, both in this file: `commit_upload` and `commit_rescan`.
558 #[allow(clippy::too_many_arguments)]
559 #[tracing::instrument(
560 skip_all,
561 name = "storage::enqueue_scan_for",
562 fields(kind = ?target_kind, %target_id, %user_id, file_size_bytes)
563 )]
564 async fn enqueue_scan_for(
565 db: &PgPool,
566 scanner: Option<&Arc<ScanPipeline>>,
567 target_kind: ScanTargetKind,
568 target_id: Uuid,
569 s3_key: &str,
570 file_type: FileType,
571 user_id: db::UserId,
572 file_size_bytes: i64,
573 ) -> Result<db::FileScanStatus> {
574 if scanner.is_none() {
575 let is_trusted = db::users::is_upload_trusted(db, user_id).await?;
576 let status = if is_trusted {
577 db::FileScanStatus::Clean
578 } else {
579 db::FileScanStatus::HeldForReview
580 };
581 tracing::info!(
582 scanner = "disabled",
583 is_trusted,
584 ?status,
585 "scanner unavailable; status assigned without enqueue"
586 );
587 return Ok(status);
588 }
589
590 db::scan_jobs::enqueue(
591 db,
592 target_kind,
593 target_id,
594 s3_key,
595 file_type,
596 user_id,
597 file_size_bytes,
598 )
599 .await?;
600
601 Ok(db::FileScanStatus::Pending)
602 }
603
604 #[cfg(test)]
605 mod tests {
606 use super::*;
607
608 const GIB: i64 = 1024 * 1024 * 1024;
609
610 #[test]
611 fn browser_validator_refuses_above_the_browser_ceiling() {
612 // 10 GiB video: within the 20 GB per-type cap, but a browser issues one
613 // unresumable presigned PUT, so it is refused above 2 GiB.
614 let err = validate_declared_upload_size(Some(10 * GIB), FileType::Video, None)
615 .expect_err("browser upload above the browser ceiling must be refused");
616 assert!(
617 matches!(err, AppError::FileTooLarge(ref m) if m.contains("CLI or desktop app")),
618 "expected a pointer to the CLI, got: {err:?}"
619 );
620 }
621
622 #[test]
623 fn multipart_validator_allows_above_the_browser_ceiling() {
624 // The same file over the chunked CLI path is fine, exceeding the
625 // one-shot ceiling is the entire point of multipart. If this ever starts
626 // failing, the 2 GiB-to-20 GB tier band is unreachable again.
627 validate_declared_upload_size_limits(Some(10 * GIB), FileType::Video, None)
628 .expect("multipart upload above the browser ceiling must be allowed");
629 }
630
631 #[test]
632 fn multipart_validator_still_enforces_the_per_type_cap() {
633 // Skipping the transport ceiling must not skip the limits that describe
634 // the file itself: 25 GiB is past FileType::Video's 20 GB cap.
635 assert!(
636 validate_declared_upload_size_limits(Some(25 * GIB), FileType::Video, None).is_err()
637 );
638 }
639
640 #[test]
641 fn multipart_validator_still_enforces_the_tier_cap() {
642 // A 10 GiB file under a 6 GiB tier cap is refused before it stages parts.
643 assert!(
644 validate_declared_upload_size_limits(
645 Some(10 * GIB),
646 FileType::Video,
647 Some(6 * GIB as u64)
648 )
649 .is_err()
650 );
651 }
652
653 #[test]
654 fn both_validators_reject_non_positive_sizes() {
655 assert!(validate_declared_upload_size(Some(0), FileType::Video, None).is_err());
656 assert!(validate_declared_upload_size_limits(Some(0), FileType::Video, None).is_err());
657 assert!(validate_declared_upload_size_limits(Some(-1), FileType::Video, None).is_err());
658 }
659
660 #[test]
661 fn absent_size_is_accepted_by_both() {
662 // No declared size: the confirm-time HEAD is the bound.
663 validate_declared_upload_size(None, FileType::Video, None).unwrap();
664 validate_declared_upload_size_limits(None, FileType::Video, None).unwrap();
665 }
666 }
667