Skip to main content

max / makenotwork

23.3 KB · 698 lines History Blame Raw
1 //! Internal upload pipeline: presigned URL generation, upload confirmation, and storage usage.
2
3 use crate::auth::InternalActor;
4 use axum::{
5 Json,
6 extract::{Query, State},
7 response::IntoResponse,
8 };
9 use serde::{Deserialize, Serialize};
10 use std::str::FromStr;
11
12 use sqlx::PgPool;
13
14 use crate::{
15 AppStorage, Scanning,
16 auth::ServiceAuth,
17 db::{self, ItemId},
18 error::{AppError, Result},
19 storage::{CACHE_CONTROL_IMMUTABLE, FileType, S3Client},
20 };
21
22 // ── Presign upload (for CLI upload pipeline) ──
23
24 #[derive(Deserialize)]
25 pub(super) struct InternalPresignRequest {
26 item_id: ItemId,
27 file_type: String,
28 file_name: String,
29 content_type: String,
30 }
31
32 #[derive(Serialize)]
33 struct InternalPresignResponse {
34 upload_url: String,
35 s3_key: String,
36 expires_in: u64,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 cache_control: Option<String>,
39 }
40
41 /// POST /api/internal/upload/presign
42 ///
43 /// Generate a presigned S3 upload URL. Used by the CLI upload pipeline.
44 #[tracing::instrument(skip_all, name = "internal::presign_upload")]
45 pub(super) async fn presign_upload(
46 State(db): State<PgPool>,
47 State(storage): State<AppStorage>,
48 actor: InternalActor,
49 _auth: ServiceAuth,
50 Json(req): Json<InternalPresignRequest>,
51 ) -> Result<impl IntoResponse> {
52 let s3 = storage.require_s3()?;
53
54 let file_type = FileType::from_str(&req.file_type)
55 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
56
57 S3Client::validate_content_type(file_type, &req.content_type)?;
58 S3Client::validate_extension(file_type, &req.file_name)?;
59
60 // Verify user owns the item
61 let owner = db::items::get_item_owner(&db, req.item_id)
62 .await?
63 .ok_or(AppError::NotFound)?;
64 if owner != actor.user_id() {
65 return Err(AppError::Forbidden);
66 }
67
68 // Early quota check
69 db::creator_tiers::check_presign_allowed(&db, actor.user_id(), file_type).await?;
70
71 // Staging key (unserved); the scan worker promotes it to the content key on a
72 // Clean verdict. See routes/storage/uploads.rs for the C1 rationale.
73 let s3_key = S3Client::generate_staging_key(&req.file_name);
74
75 // Track the pending upload so the reaper can clean it up if never confirmed
76 db::pending_uploads::record_pending_upload(&db, actor.user_id(), &s3_key, "main").await?;
77
78 let expires_in = 3600;
79 let upload_url = s3
80 .presign_upload(
81 &s3_key,
82 &req.content_type,
83 Some(expires_in),
84 Some(CACHE_CONTROL_IMMUTABLE),
85 None,
86 )
87 .await?;
88
89 Ok(Json(InternalPresignResponse {
90 upload_url,
91 s3_key: s3_key.into_string(),
92 expires_in,
93 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
94 }))
95 }
96
97 // ── Multipart upload session (CLI / desktop, large files) ──
98 //
99 // The chunked counterpart to `presign_upload`, and deliberately only on the
100 // internal (CLI/desktop) surface: a browser keeps the one-shot presigned PUT and
101 // its 2 GiB ceiling, because a tab cannot resume a multi-hour transfer. These
102 // three endpoints replace the *transport* only, the client finishes by calling
103 // the existing `/api/internal/upload/confirm`, which reads the authoritative
104 // object size from S3 and does all the size/tier/scan/DB work unchanged.
105
106 /// Largest window of presigned part URLs one `parts` call will mint. A 20 GB
107 /// object is ~1250 parts at the auto-chosen part size; handing out every URL at
108 /// once would mint thousands of credentials with a 1-hour life for an upload
109 /// that may never happen, so the client pulls them in windows as it progresses.
110 const MULTIPART_PART_URL_WINDOW: u32 = 100;
111
112 #[derive(Deserialize)]
113 pub(super) struct MultipartStartRequest {
114 item_id: ItemId,
115 file_type: String,
116 file_name: String,
117 content_type: String,
118 file_size_bytes: i64,
119 }
120
121 #[derive(Serialize)]
122 struct MultipartStartResponse {
123 upload_id: String,
124 s3_key: String,
125 part_size: usize,
126 part_count: u32,
127 expires_in: u64,
128 }
129
130 /// POST /api/internal/upload/multipart/start
131 ///
132 /// Open a multipart upload session and return the part geometry the client
133 /// uploads against. Mirrors `presign_upload`'s pre-checks (ownership, type,
134 /// quota) and additionally validates the declared size, since a multipart
135 /// session stages real S3 state that a rejected upload would orphan.
136 #[tracing::instrument(skip_all, name = "internal::multipart_start")]
137 pub(super) async fn multipart_start(
138 State(db): State<PgPool>,
139 State(storage): State<AppStorage>,
140 actor: InternalActor,
141 _auth: ServiceAuth,
142 Json(req): Json<MultipartStartRequest>,
143 ) -> Result<impl IntoResponse> {
144 let s3 = storage.require_s3()?;
145
146 let file_type = FileType::from_str(&req.file_type)
147 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
148
149 S3Client::validate_content_type(file_type, &req.content_type)?;
150 S3Client::validate_extension(file_type, &req.file_name)?;
151
152 let owner = db::items::get_item_owner(&db, req.item_id)
153 .await?
154 .ok_or(AppError::NotFound)?;
155 if owner != actor.user_id() {
156 return Err(AppError::Forbidden);
157 }
158
159 db::creator_tiers::check_presign_allowed(&db, actor.user_id(), file_type).await?;
160
161 // Every size limit that describes the file, but NOT the single-PUT ceiling,
162 // chunking is precisely what lets this path exceed it. The tier's per-file
163 // cap is enforced here so an over-cap file is refused before it stages parts;
164 // confirm re-checks against the real object size regardless.
165 let tier_cap = db::creator_tiers::get_active_creator_tier(&db, actor.user_id())
166 .await?
167 .map(|t| t.max_file_bytes() as u64);
168 crate::routes::storage::validate_declared_upload_size_limits(
169 Some(req.file_size_bytes),
170 file_type,
171 tier_cap,
172 )?;
173
174 // Part geometry is pure arithmetic over the declared size, so the client can
175 // derive identical boundaries without a round trip.
176 let plan = s3_storage::MultipartPlan::auto(req.file_size_bytes.max(0) as u64)
177 .map_err(AppError::BadRequest)?;
178
179 let s3_key = S3Client::generate_staging_key(&req.file_name);
180 // Persist the tier-checked declared size so `multipart_parts` binds the part
181 // geometry to it instead of trusting its own request body (deepaudit F1).
182 db::pending_uploads::record_pending_multipart_upload(
183 &db,
184 actor.user_id(),
185 &s3_key,
186 "main",
187 req.file_size_bytes,
188 )
189 .await?;
190
191 let upload_id = s3
192 .create_multipart_upload(&s3_key, &req.content_type)
193 .await?;
194
195 tracing::info!(
196 user = %actor.user_id(), item = %req.item_id, s3_key = %s3_key,
197 size = req.file_size_bytes, parts = plan.part_count,
198 "CLI multipart upload started"
199 );
200
201 Ok(Json(MultipartStartResponse {
202 upload_id,
203 s3_key: s3_key.into_string(),
204 part_size: plan.part_size,
205 part_count: plan.part_count,
206 expires_in: 3600,
207 }))
208 }
209
210 #[derive(Deserialize)]
211 pub(super) struct MultipartPartsRequest {
212 s3_key: String,
213 upload_id: String,
214 /// Declared total size, so each part URL can be signed with its exact
215 /// `Content-Length`. The plan is deterministic in this value, so it must
216 /// match the one passed to `start` or the signed lengths will not line up.
217 file_size_bytes: i64,
218 first_part: u32,
219 count: u32,
220 }
221
222 #[derive(Serialize)]
223 struct MultipartPartUrl {
224 part_number: i32,
225 content_length: u64,
226 url: String,
227 }
228
229 #[derive(Serialize)]
230 struct MultipartPartsResponse {
231 parts: Vec<MultipartPartUrl>,
232 expires_in: u64,
233 }
234
235 /// POST /api/internal/upload/multipart/parts
236 ///
237 /// Mint a bounded window of presigned `UploadPart` URLs. Each carries its exact
238 /// signed `Content-Length`, the same defense-in-depth the single-PUT presign
239 /// applies.
240 #[tracing::instrument(skip_all, name = "internal::multipart_parts")]
241 pub(super) async fn multipart_parts(
242 State(db): State<PgPool>,
243 State(storage): State<AppStorage>,
244 actor: InternalActor,
245 _auth: ServiceAuth,
246 Json(req): Json<MultipartPartsRequest>,
247 ) -> Result<impl IntoResponse> {
248 let s3 = storage.require_s3()?;
249 let s3_key = authorize_multipart_key(&db, actor.user_id(), &req.s3_key).await?;
250
251 // F1: the part geometry must come from the size `start` validated against the
252 // tier cap, not from this request body. Read it back and bind the request to
253 // it, so a session opened for 1 GB cannot mint 5 TiB of part URLs.
254 let declared = db::pending_uploads::declared_size(&db, actor.user_id(), &req.s3_key, "main")
255 .await?
256 .ok_or_else(|| {
257 AppError::BadRequest("no multipart session was started for this key".to_string())
258 })?;
259 if req.file_size_bytes != declared {
260 return Err(AppError::BadRequest(format!(
261 "file_size_bytes {} does not match the size declared at start ({declared})",
262 req.file_size_bytes
263 )));
264 }
265 // F4: this session is actively receiving parts, so refresh its liveness and
266 // keep the 24h orphan reaper from aborting a legitimate slow transfer.
267 db::pending_uploads::touch_pending_upload(&db, actor.user_id(), &req.s3_key, "main").await?;
268
269 let plan =
270 s3_storage::MultipartPlan::auto(declared.max(0) as u64).map_err(AppError::BadRequest)?;
271
272 if req.count == 0 || req.count > MULTIPART_PART_URL_WINDOW {
273 return Err(AppError::BadRequest(format!(
274 "count must be between 1 and {MULTIPART_PART_URL_WINDOW}"
275 )));
276 }
277 if req.first_part == 0 || req.first_part > plan.part_count {
278 return Err(AppError::BadRequest(format!(
279 "first_part must be between 1 and {}",
280 plan.part_count
281 )));
282 }
283
284 let expires_in = 3600u64;
285 let last = (req.first_part + req.count - 1).min(plan.part_count);
286 let mut parts = Vec::with_capacity((last - req.first_part + 1) as usize);
287 for part_number in req.first_part..=last {
288 let content_length = plan.part_len(part_number);
289 let url = s3
290 .presign_upload_part(
291 &s3_key,
292 &req.upload_id,
293 part_number as i32,
294 Some(expires_in),
295 Some(content_length as i64),
296 // No checksum: the CLI streams plain file bytes and asks for
297 // part URLs ahead of reading them, so it has no digest to bind
298 // yet. The synckit blob path, which seals a part before asking
299 // for its URL, does bind one.
300 None,
301 )
302 .await?;
303 parts.push(MultipartPartUrl {
304 part_number: part_number as i32,
305 content_length,
306 url,
307 });
308 }
309
310 Ok(Json(MultipartPartsResponse { parts, expires_in }))
311 }
312
313 #[derive(Deserialize)]
314 pub(super) struct MultipartCompletedPart {
315 part_number: i32,
316 etag: String,
317 }
318
319 #[derive(Deserialize)]
320 pub(super) struct MultipartCompleteRequest {
321 s3_key: String,
322 upload_id: String,
323 parts: Vec<MultipartCompletedPart>,
324 }
325
326 #[derive(Serialize)]
327 struct MultipartCompleteResponse {
328 success: bool,
329 s3_key: String,
330 }
331
332 /// POST /api/internal/upload/multipart/complete
333 ///
334 /// Assemble the uploaded parts into the staging object. This finalizes the
335 /// transport only; the client then calls `/api/internal/upload/confirm`, which
336 /// applies every size/tier/scan/commit rule against the real object.
337 #[tracing::instrument(skip_all, name = "internal::multipart_complete")]
338 pub(super) async fn multipart_complete(
339 State(db): State<PgPool>,
340 State(storage): State<AppStorage>,
341 actor: InternalActor,
342 _auth: ServiceAuth,
343 Json(req): Json<MultipartCompleteRequest>,
344 ) -> Result<impl IntoResponse> {
345 let s3 = storage.require_s3()?;
346 let s3_key = authorize_multipart_key(&db, actor.user_id(), &req.s3_key).await?;
347
348 let parts: Vec<(i32, String)> = req
349 .parts
350 .into_iter()
351 .map(|p| (p.part_number, p.etag))
352 .collect();
353
354 s3.complete_multipart_upload(&s3_key, &req.upload_id, &parts)
355 .await?;
356
357 tracing::info!(
358 user = %actor.user_id(), s3_key = %s3_key, parts = parts.len(),
359 "CLI multipart upload completed"
360 );
361
362 Ok(Json(MultipartCompleteResponse {
363 success: true,
364 s3_key: s3_key.into_string(),
365 }))
366 }
367
368 #[derive(Deserialize)]
369 pub(super) struct MultipartAbortRequest {
370 s3_key: String,
371 upload_id: String,
372 }
373
374 /// POST /api/internal/upload/multipart/abort
375 ///
376 /// Release the parts of an abandoned session (client cancel). Incomplete
377 /// multipart uploads bill for their parts until aborted, so the client cleaning
378 /// up on cancel is the cheapest fix; the pending-upload reaper is the backstop
379 /// for clients that vanish.
380 #[tracing::instrument(skip_all, name = "internal::multipart_abort")]
381 pub(super) async fn multipart_abort(
382 State(db): State<PgPool>,
383 State(storage): State<AppStorage>,
384 actor: InternalActor,
385 _auth: ServiceAuth,
386 Json(req): Json<MultipartAbortRequest>,
387 ) -> Result<impl IntoResponse> {
388 let s3 = storage.require_s3()?;
389 let s3_key = authorize_multipart_key(&db, actor.user_id(), &req.s3_key).await?;
390
391 s3.abort_multipart_upload(&s3_key, &req.upload_id).await?;
392
393 tracing::info!(user = %actor.user_id(), s3_key = %s3_key, "CLI multipart upload aborted");
394 Ok(Json(InternalConfirmResponse { success: true }))
395 }
396
397 /// Bind a caller-supplied staging key to the caller.
398 ///
399 /// A `staging/{uuid}` key carries no user in its path, so ownership comes from
400 /// the `pending_uploads` row `start` recorded, the same proof `confirm_upload`
401 /// uses. Without this, any authenticated creator could drive parts into another
402 /// creator's in-flight session.
403 async fn authorize_multipart_key(
404 db: &PgPool,
405 user_id: crate::db::UserId,
406 s3_key: &str,
407 ) -> Result<crate::storage::S3Key> {
408 if !db::pending_uploads::is_owned(db, user_id, s3_key, "main").await? {
409 return Err(AppError::BadRequest("Invalid upload key".to_string()));
410 }
411 Ok(crate::storage::S3Key::from_stored(s3_key))
412 }
413
414 // ── Confirm upload (for CLI upload pipeline) ──
415
416 #[derive(Deserialize)]
417 pub(super) struct InternalConfirmRequest {
418 item_id: ItemId,
419 file_type: String,
420 s3_key: String,
421 }
422
423 #[derive(Serialize)]
424 struct InternalConfirmResponse {
425 success: bool,
426 }
427
428 /// POST /api/internal/upload/confirm
429 ///
430 /// Confirm a completed S3 upload: verify, scan, update DB. Used by the CLI upload pipeline.
431 #[tracing::instrument(skip_all, name = "internal::confirm_upload")]
432 pub(super) async fn confirm_upload(
433 State(db): State<PgPool>,
434 State(storage): State<AppStorage>,
435 State(scanning): State<Scanning>,
436 actor: InternalActor,
437 _auth: ServiceAuth,
438 Json(req): Json<InternalConfirmRequest>,
439 ) -> Result<impl IntoResponse> {
440 let s3 = storage.require_s3()?;
441
442 let file_type = FileType::from_str(&req.file_type)
443 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
444
445 // Verify user owns the item
446 let owner = db::items::get_item_owner(&db, req.item_id)
447 .await?
448 .ok_or(AppError::NotFound)?;
449 if owner != actor.user_id() {
450 return Err(AppError::Forbidden);
451 }
452
453 // Ownership of the staging key is proved below (after the idempotent-replay
454 // short-circuit) via `pending_uploads`, a `staging/{uuid}` key carries no
455 // user/item in its path for a prefix check to bind against.
456
457 // Verify the object exists in S3
458 if !s3.object_exists(&req.s3_key).await? {
459 return Err(AppError::BadRequest(
460 "Upload not found. Please try uploading again.".to_string(),
461 ));
462 }
463
464 // Idempotent replay: the S3 key is deterministic (`{user}/{item}/...`), and this
465 // handler is non-atomic (storage increment + item/version writes). A retried
466 // confirm for a key already committed must not re-charge storage or create a
467 // duplicate version, detect the committed state and return success without
468 // repeating the side effects (ultra-fuzz Run 12 Storage: confirm idempotency).
469 let already_committed = match file_type {
470 FileType::Audio | FileType::Video => {
471 match db::items::get_item_by_id(&db, req.item_id).await? {
472 Some(item) if file_type == FileType::Audio => {
473 item.audio_s3_key.as_deref() == Some(req.s3_key.as_str())
474 }
475 Some(item) => item.video_s3_key.as_deref() == Some(req.s3_key.as_str()),
476 None => false,
477 }
478 }
479 FileType::Download => db::versions::get_versions_by_item(&db, req.item_id)
480 .await?
481 .iter()
482 .any(|v| v.s3_key.as_deref() == Some(req.s3_key.as_str())),
483 _ => false,
484 };
485 if already_committed {
486 tracing::info!(
487 user = %actor.user_id(), item = %req.item_id, s3_key = %req.s3_key,
488 "CLI upload confirm replay, already committed, skipping duplicate side effects"
489 );
490 return Ok(Json(InternalConfirmResponse { success: true }));
491 }
492
493 // Authorize the staging key for a fresh confirm: the caller must have
494 // presigned it (recorded against them in `pending_uploads`). Placed after the
495 // replay short-circuit, which consumed the pending row on the first confirm,
496 // and before any reject path that enqueues the object for deletion, so an
497 // unowned (at most another user's in-flight) staging object is never touched.
498 if !db::pending_uploads::is_owned(&db, actor.user_id(), &req.s3_key, "main").await? {
499 return Err(AppError::BadRequest("Invalid upload key".to_string()));
500 }
501
502 // Enforce file size limit
503 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
504 AppError::BadRequest(
505 "Could not determine file size. Please try uploading again.".to_string(),
506 )
507 })?;
508 if file_size_bytes as u64 > file_type.max_size() {
509 crate::routes::storage::enqueue_s3_orphan(
510 &db,
511 &req.s3_key,
512 crate::storage::S3Bucket::Main,
513 "cli_upload_rejected",
514 )
515 .await;
516 return Err(AppError::BadRequest(format!(
517 "File exceeds maximum size of {} MB",
518 file_type.max_size() / (1024 * 1024)
519 )));
520 }
521
522 // Enforce tier-based limits
523 let max_storage = match db::creator_tiers::check_upload_allowed(
524 &db,
525 actor.user_id(),
526 file_type,
527 file_size_bytes,
528 )
529 .await
530 {
531 Ok(max) => max,
532 Err(e) => {
533 crate::routes::storage::enqueue_s3_orphan(
534 &db,
535 &req.s3_key,
536 crate::storage::S3Bucket::Main,
537 "cli_upload_rejected",
538 )
539 .await;
540 return Err(e);
541 }
542 };
543
544 // Reject unsupported file types BEFORE any side effect, same ordering rule
545 // as the web upload handlers (see routes/storage/mod.rs::commit_upload).
546 if !matches!(
547 file_type,
548 FileType::Audio | FileType::Download | FileType::Video
549 ) {
550 crate::routes::storage::enqueue_s3_orphan(
551 &db,
552 &req.s3_key,
553 crate::storage::S3Bucket::Main,
554 "cli_upload_rejected",
555 )
556 .await;
557 return Err(AppError::BadRequest(
558 "CLI upload only supports audio, video, and download file types".to_string(),
559 ));
560 }
561
562 // Increment storage BEFORE writing the DB record (if quota exceeded, the
563 // S3 object is cleaned up and no DB record is created).
564 if let Err(e) =
565 db::creator_tiers::try_increment_storage(&db, actor.user_id(), file_size_bytes, max_storage)
566 .await
567 {
568 crate::routes::storage::enqueue_s3_orphan(
569 &db,
570 &req.s3_key,
571 crate::storage::S3Bucket::Main,
572 "cli_upload_rejected",
573 )
574 .await;
575 return Err(e);
576 }
577
578 db::pending_uploads::remove_pending_upload(&db, actor.user_id(), &req.s3_key, "main").await?;
579
580 // Update the database with S3 key and file size.
581 let file_name = req
582 .s3_key
583 .rsplit('/')
584 .next()
585 .map(std::string::ToString::to_string);
586 let commit_target = match file_type {
587 FileType::Audio => {
588 db::items::update_item_audio_s3_key(&db, req.item_id, actor.user_id(), &req.s3_key)
589 .await?;
590 db::items::update_item_audio_file_size(
591 &db,
592 req.item_id,
593 actor.user_id(),
594 file_size_bytes,
595 )
596 .await?;
597 crate::routes::storage::CommitTarget::Item(req.item_id)
598 }
599 FileType::Download => {
600 let version = db::versions::create_version(
601 &db,
602 req.item_id,
603 "1.0",
604 None,
605 Some(&req.s3_key),
606 Some(file_size_bytes),
607 file_name.as_deref(),
608 None,
609 )
610 .await?;
611 crate::routes::storage::CommitTarget::Version(version.id)
612 }
613 FileType::Video => {
614 db::items::update_item_video_s3_key(&db, req.item_id, actor.user_id(), &req.s3_key)
615 .await?;
616 db::items::update_item_video_file_size(
617 &db,
618 req.item_id,
619 actor.user_id(),
620 file_size_bytes,
621 )
622 .await?;
623 crate::routes::storage::CommitTarget::Item(req.item_id)
624 }
625 _ => unreachable!("guarded above"),
626 };
627
628 // Scan enqueue + scan_status flip AFTER the DB writes commit, chronic
629 // ordering invariant enforced via the shared commit_upload helper.
630 let _status = crate::routes::storage::commit_upload(
631 &db,
632 scanning.scanner.as_ref(),
633 commit_target,
634 &req.s3_key,
635 file_type,
636 actor.user_id(),
637 file_size_bytes,
638 )
639 .await?;
640
641 // Bump project cache
642 if let Some(item) = db::items::get_item_by_id(&db, req.item_id).await?
643 && let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await
644 {
645 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after upload");
646 }
647
648 tracing::info!(
649 user = %actor.user_id(),
650 item = %req.item_id,
651 file_type = ?file_type,
652 s3_key = %req.s3_key,
653 size = file_size_bytes,
654 "CLI upload confirmed"
655 );
656
657 Ok(Json(InternalConfirmResponse { success: true }))
658 }
659
660 // ── Storage info ──
661
662 #[derive(Deserialize)]
663 pub(super) struct UserIdQuery {}
664
665 #[derive(Serialize)]
666 struct StorageInfoResponse {
667 storage_used_bytes: i64,
668 max_storage_bytes: i64,
669 allows_file_uploads: bool,
670 }
671
672 /// GET /api/internal/creator/storage?user_id={uuid}
673 ///
674 /// Get storage usage and limits for a creator.
675 #[tracing::instrument(skip_all, name = "internal::creator_storage")]
676 pub(super) async fn creator_storage(
677 State(db): State<PgPool>,
678 actor: InternalActor,
679 _auth: ServiceAuth,
680 Query(_query): Query<UserIdQuery>,
681 ) -> Result<impl IntoResponse> {
682 let used = db::creator_tiers::get_storage_used(&db, actor.user_id()).await?;
683
684 // Resolve effective tier
685 let tier = db::creator_tiers::get_active_creator_tier(&db, actor.user_id()).await?;
686
687 let (max_storage, allows_uploads) = match tier {
688 Some(t) => (t.max_storage_bytes(), t.allows_file_uploads()),
689 None => (0, false),
690 };
691
692 Ok(Json(StorageInfoResponse {
693 storage_used_bytes: used,
694 max_storage_bytes: max_storage,
695 allows_file_uploads: allows_uploads,
696 }))
697 }
698