max / makenotwork
10 files changed,
+281 insertions,
-217 deletions
| @@ -263,7 +263,7 @@ pub(super) async fn admin_promote_item( | |||
| 263 | 263 | // Copy each held (staging) file to its content key and mark Clean in one | |
| 264 | 264 | // step — a held file sits unserved at its staging key, so approving must run | |
| 265 | 265 | // the same promote the scan worker's Clean path runs (C1). | |
| 266 | - | crate::routes::storage::commit_promote_item(&state, id).await?; | |
| 266 | + | crate::routes::storage::commit_promote_item(&state.db, &state.storage, &state.config, id).await?; | |
| 267 | 267 | db::scan_admin_actions::log_item( | |
| 268 | 268 | &state.db, id, admin.id, AdminAction::Promote, | |
| 269 | 269 | Some("held_for_review"), Some("clean"), | |
| @@ -299,7 +299,7 @@ pub(super) async fn admin_promote_version( | |||
| 299 | 299 | ) -> Result<Response> { | |
| 300 | 300 | // See admin_promote_item: copy the held (staging) download to its content key | |
| 301 | 301 | // and mark Clean together (C1). | |
| 302 | - | crate::routes::storage::commit_promote_version(&state, id).await?; | |
| 302 | + | crate::routes::storage::commit_promote_version(&state.db, &state.storage, &state.config, id).await?; | |
| 303 | 303 | db::scan_admin_actions::log_version( | |
| 304 | 304 | &state.db, id, admin.id, AdminAction::Promote, | |
| 305 | 305 | Some("held_for_review"), Some("clean"), | |
| @@ -361,7 +361,8 @@ async fn rescan_version_inner( | |||
| 361 | 361 | // to call scan_jobs::enqueue + update_*_scan_status directly, which made | |
| 362 | 362 | // ordering bugs hard to fence at the type level. | |
| 363 | 363 | crate::routes::storage::commit_rescan( | |
| 364 | - | state, | |
| 364 | + | &state.db, | |
| 365 | + | state.scanner.as_ref(), | |
| 365 | 366 | crate::routes::storage::CommitTarget::Version(id), | |
| 366 | 367 | &s3_key, | |
| 367 | 368 | FileType::Download, | |
| @@ -407,7 +408,8 @@ async fn rescan_item_inner( | |||
| 407 | 408 | }; | |
| 408 | 409 | ||
| 409 | 410 | crate::routes::storage::commit_rescan( | |
| 410 | - | state, | |
| 411 | + | &state.db, | |
| 412 | + | state.scanner.as_ref(), | |
| 411 | 413 | crate::routes::storage::CommitTarget::Item(id), | |
| 412 | 414 | &s3_key, | |
| 413 | 415 | file_type, | |
| @@ -509,7 +511,7 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 509 | 511 | let admin_id = admin.id; | |
| 510 | 512 | let vid = v.version_id; | |
| 511 | 513 | set.spawn(async move { | |
| 512 | - | if let Err(e) = crate::routes::storage::commit_promote_version(&state, vid).await { | |
| 514 | + | if let Err(e) = crate::routes::storage::commit_promote_version(&state.db, &state.storage, &state.config, vid).await { | |
| 513 | 515 | tracing::warn!(version_id = %vid, error = ?e, "bulk promote: version promote failed"); | |
| 514 | 516 | false | |
| 515 | 517 | } else { | |
| @@ -530,7 +532,7 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 530 | 532 | let admin_id = admin.id; | |
| 531 | 533 | let iid = i.item_id; | |
| 532 | 534 | set.spawn(async move { | |
| 533 | - | if let Err(e) = crate::routes::storage::commit_promote_item(&state, iid).await { | |
| 535 | + | if let Err(e) = crate::routes::storage::commit_promote_item(&state.db, &state.storage, &state.config, iid).await { | |
| 534 | 536 | tracing::warn!(item_id = %iid, error = ?e, "bulk promote: item promote failed"); | |
| 535 | 537 | false | |
| 536 | 538 | } else { |
| @@ -219,7 +219,8 @@ pub(super) async fn confirm_insertion( | |||
| 219 | 219 | // worker flips it to 'clean'; on quarantine the row is purged and a WAM ticket | |
| 220 | 220 | // filed. The creator still sees the pending clip in their management library. | |
| 221 | 221 | commit_upload( | |
| 222 | - | &state, | |
| 222 | + | &state.db, | |
| 223 | + | state.scanner.as_ref(), | |
| 223 | 224 | CommitTarget::ContentInsertion(insertion.id), | |
| 224 | 225 | &req.s3_key, | |
| 225 | 226 | FileType::Insertion, |
| @@ -258,7 +258,8 @@ pub(super) async fn confirm_upload( | |||
| 258 | 258 | // Scan enqueue + scan_status flip AFTER the DB writes commit — chronic | |
| 259 | 259 | // ordering invariant enforced via the shared commit_upload helper. | |
| 260 | 260 | let _status = crate::routes::storage::commit_upload( | |
| 261 | - | &state, | |
| 261 | + | &state.db, | |
| 262 | + | state.scanner.as_ref(), | |
| 262 | 263 | commit_target, | |
| 263 | 264 | &req.s3_key, | |
| 264 | 265 | file_type, |
| @@ -6,13 +6,13 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Serialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::MaybeUserVerified, | |
| 12 | 13 | db::{self, ContentData, ItemId, VersionId}, | |
| 13 | 14 | error::{AppError, Result, ResultExt}, | |
| 14 | - | pricing, | |
| 15 | - | AppState, | |
| 15 | + | pricing, AppStorage, | |
| 16 | 16 | }; | |
| 17 | 17 | ||
| 18 | 18 | /// JSON response containing a presigned streaming/download URL. | |
| @@ -64,23 +64,24 @@ async fn resolve_content_url( | |||
| 64 | 64 | /// - Paid items: Must be logged in and have purchased the item | |
| 65 | 65 | #[tracing::instrument(skip_all, name = "storage::stream_url", fields(item_id))] | |
| 66 | 66 | pub(super) async fn stream_url( | |
| 67 | - | State(state): State<AppState>, | |
| 67 | + | State(db): State<PgPool>, | |
| 68 | + | State(storage): State<AppStorage>, | |
| 68 | 69 | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 69 | 70 | Path(item_id): Path<ItemId>, | |
| 70 | 71 | ) -> Result<impl IntoResponse> { | |
| 71 | 72 | tracing::Span::current().record("item_id", tracing::field::display(&item_id)); | |
| 72 | 73 | ||
| 73 | 74 | // Check if S3 is configured | |
| 74 | - | let s3 = state.require_s3()?; | |
| 75 | + | let s3 = storage.require_s3()?; | |
| 75 | 76 | ||
| 76 | 77 | // Get the item | |
| 77 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 78 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 78 | 79 | .await? | |
| 79 | 80 | .ok_or(AppError::NotFound)?; | |
| 80 | 81 | ||
| 81 | 82 | // Single-query access check: ownership, purchase, subscription, bundle | |
| 82 | 83 | let user_id = maybe_user.as_ref().map(|u| u.id); | |
| 83 | - | let access = db::items::check_item_access(&state.db, item_id, user_id) | |
| 84 | + | let access = db::items::check_item_access(&db, item_id, user_id) | |
| 84 | 85 | .await? | |
| 85 | 86 | .ok_or(AppError::NotFound)?; | |
| 86 | 87 | let is_creator = user_id.is_some_and(|uid| uid == access.owner_id); | |
| @@ -142,11 +143,11 @@ pub(super) async fn stream_url( | |||
| 142 | 143 | ).await?; | |
| 143 | 144 | ||
| 144 | 145 | // Increment total play count (includes replays) | |
| 145 | - | db::items::increment_play_count(&state.db, item_id).await?; | |
| 146 | + | db::items::increment_play_count(&db, item_id).await?; | |
| 146 | 147 | ||
| 147 | 148 | // Track unique listeners for authenticated users | |
| 148 | 149 | if let Some(ref user) = maybe_user { | |
| 149 | - | let _ = db::items::record_unique_play(&state.db, user.id, item_id).await; | |
| 150 | + | let _ = db::items::record_unique_play(&db, user.id, item_id).await; | |
| 150 | 151 | } | |
| 151 | 152 | ||
| 152 | 153 | Ok(Json(StreamUrlResponse { | |
| @@ -162,15 +163,16 @@ pub(super) async fn stream_url( | |||
| 162 | 163 | /// Access control: free items are accessible to anyone, paid items require purchase. | |
| 163 | 164 | #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))] | |
| 164 | 165 | pub(super) async fn version_download( | |
| 165 | - | State(state): State<AppState>, | |
| 166 | + | State(db): State<PgPool>, | |
| 167 | + | State(storage): State<AppStorage>, | |
| 166 | 168 | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 167 | 169 | Path(version_id): Path<VersionId>, | |
| 168 | 170 | ) -> Result<impl IntoResponse> { | |
| 169 | 171 | tracing::Span::current().record("version_id", tracing::field::display(&version_id)); | |
| 170 | - | let s3 = state.require_s3()?; | |
| 172 | + | let s3 = storage.require_s3()?; | |
| 171 | 173 | ||
| 172 | 174 | // Fetch version | |
| 173 | - | let version = db::versions::get_version_by_id(&state.db, version_id) | |
| 175 | + | let version = db::versions::get_version_by_id(&db, version_id) | |
| 174 | 176 | .await? | |
| 175 | 177 | .ok_or(AppError::NotFound)?; | |
| 176 | 178 | ||
| @@ -181,13 +183,13 @@ pub(super) async fn version_download( | |||
| 181 | 183 | .ok_or(AppError::NotFound)?; | |
| 182 | 184 | ||
| 183 | 185 | // Fetch item for access control | |
| 184 | - | let item = db::items::get_item_by_id(&state.db, version.item_id) | |
| 186 | + | let item = db::items::get_item_by_id(&db, version.item_id) | |
| 185 | 187 | .await? | |
| 186 | 188 | .ok_or(AppError::NotFound)?; | |
| 187 | 189 | ||
| 188 | 190 | // Single-query access check: ownership, purchase, subscription, bundle | |
| 189 | 191 | let user_id = maybe_user.as_ref().map(|u| u.id); | |
| 190 | - | let access = db::items::check_item_access(&state.db, version.item_id, user_id) | |
| 192 | + | let access = db::items::check_item_access(&db, version.item_id, user_id) | |
| 191 | 193 | .await? | |
| 192 | 194 | .ok_or(AppError::NotFound)?; | |
| 193 | 195 | let is_creator = user_id.is_some_and(|uid| uid == access.owner_id); | |
| @@ -228,12 +230,12 @@ pub(super) async fn version_download( | |||
| 228 | 230 | ).await?; | |
| 229 | 231 | ||
| 230 | 232 | // Increment per-version and item-level download counts | |
| 231 | - | db::versions::increment_download_count(&state.db, version_id).await?; | |
| 232 | - | db::items::increment_item_download_count(&state.db, version.item_id).await?; | |
| 233 | + | db::versions::increment_download_count(&db, version_id).await?; | |
| 234 | + | db::items::increment_item_download_count(&db, version.item_id).await?; | |
| 233 | 235 | ||
| 234 | 236 | // Track per-user download for library "new version" indicators | |
| 235 | 237 | if let Some(ref user) = maybe_user { | |
| 236 | - | let _ = db::versions::record_user_download(&state.db, user.id, version.item_id, version_id).await; | |
| 238 | + | let _ = db::versions::record_user_download(&db, user.id, version.item_id, version_id).await; | |
| 237 | 239 | } | |
| 238 | 240 | ||
| 239 | 241 | let license_url = if item.license_preset.is_some() { |
| @@ -13,14 +13,16 @@ use axum::{ | |||
| 13 | 13 | Json, | |
| 14 | 14 | }; | |
| 15 | 15 | use serde::{Deserialize, Serialize}; | |
| 16 | + | use sqlx::PgPool; | |
| 16 | 17 | use uuid::Uuid; | |
| 17 | 18 | ||
| 18 | 19 | use crate::{ | |
| 19 | 20 | auth::AuthUser, | |
| 21 | + | config::Config, | |
| 20 | 22 | db::{self, ImageId, ItemId, ProjectId, UserId}, | |
| 21 | 23 | error::{AppError, Result, ResultExt}, | |
| 22 | 24 | storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 23 | - | AppState, | |
| 25 | + | AppStorage, Scanning, | |
| 24 | 26 | }; | |
| 25 | 27 | ||
| 26 | 28 | use super::{commit_upload, CommitTarget, PresignUploadResponse}; | |
| @@ -45,14 +47,14 @@ impl GalleryTarget { | |||
| 45 | 47 | /// Verify `user` owns the target entity. Returns the parsed kind. NotFound if | |
| 46 | 48 | /// the entity does not exist, Forbidden if owned by someone else. | |
| 47 | 49 | async fn require_owned( | |
| 48 | - | state: &AppState, | |
| 50 | + | db: &PgPool, | |
| 49 | 51 | target: GalleryTarget, | |
| 50 | 52 | target_id: Uuid, | |
| 51 | 53 | user_id: UserId, | |
| 52 | 54 | ) -> Result<()> { | |
| 53 | 55 | 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 | + | GalleryTarget::Item => db::items::get_item_owner(db, ItemId::from(target_id)).await?, | |
| 57 | + | GalleryTarget::Project => db::projects::get_project_by_id(db, ProjectId::from(target_id)) | |
| 56 | 58 | .await? | |
| 57 | 59 | .map(|p| p.user_id), | |
| 58 | 60 | }; | |
| @@ -78,16 +80,16 @@ struct GalleryListItem { | |||
| 78 | 80 | /// Drives the wizard manager (the public page renders the same rows server-side). | |
| 79 | 81 | #[tracing::instrument(skip_all, name = "storage::gallery_list", fields(user_id = %user.id, %target_type, %target_id))] | |
| 80 | 82 | pub(super) async fn gallery_list( | |
| 81 | - | State(state): State<AppState>, | |
| 83 | + | State(db): State<PgPool>, | |
| 82 | 84 | AuthUser(user): AuthUser, | |
| 83 | 85 | Path((target_type, target_id)): Path<(String, Uuid)>, | |
| 84 | 86 | ) -> Result<impl IntoResponse> { | |
| 85 | 87 | let target = GalleryTarget::parse(&target_type)?; | |
| 86 | - | require_owned(&state, target, target_id, user.id).await?; | |
| 88 | + | require_owned(&db, target, target_id, user.id).await?; | |
| 87 | 89 | ||
| 88 | 90 | 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 | + | GalleryTarget::Item => db::gallery_images::list_for_item(&db, ItemId::from(target_id)).await?, | |
| 92 | + | GalleryTarget::Project => db::gallery_images::list_for_project(&db, ProjectId::from(target_id)).await?, | |
| 91 | 93 | }; | |
| 92 | 94 | let items: Vec<GalleryListItem> = rows | |
| 93 | 95 | .into_iter() | |
| @@ -115,24 +117,25 @@ pub struct GalleryPresignRequest { | |||
| 115 | 117 | /// POST /api/gallery/presign — presign a gallery image upload. | |
| 116 | 118 | #[tracing::instrument(skip_all, name = "storage::gallery_presign", fields(user_id = %user.id))] | |
| 117 | 119 | pub(super) async fn gallery_presign( | |
| 118 | - | State(state): State<AppState>, | |
| 120 | + | State(db): State<PgPool>, | |
| 121 | + | State(storage): State<AppStorage>, | |
| 119 | 122 | AuthUser(user): AuthUser, | |
| 120 | 123 | Json(req): Json<GalleryPresignRequest>, | |
| 121 | 124 | ) -> Result<impl IntoResponse> { | |
| 122 | 125 | user.check_not_suspended()?; | |
| 123 | - | let s3 = state.require_s3()?; | |
| 126 | + | let s3 = storage.require_s3()?; | |
| 124 | 127 | let target = GalleryTarget::parse(&req.target_type)?; | |
| 125 | 128 | ||
| 126 | 129 | let file_type = FileType::Cover; | |
| 127 | 130 | S3Client::validate_content_type(file_type, &req.content_type)?; | |
| 128 | 131 | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 129 | 132 | ||
| 130 | - | require_owned(&state, target, req.target_id, user.id).await?; | |
| 133 | + | require_owned(&db, target, req.target_id, user.id).await?; | |
| 131 | 134 | ||
| 132 | 135 | // Early per-entity cap check (authoritative re-check happens at confirm). | |
| 133 | 136 | let count = match target { | |
| 134 | - | GalleryTarget::Item => db::gallery_images::count_for_item(&state.db, ItemId::from(req.target_id)).await?, | |
| 135 | - | GalleryTarget::Project => db::gallery_images::count_for_project(&state.db, ProjectId::from(req.target_id)).await?, | |
| 137 | + | GalleryTarget::Item => db::gallery_images::count_for_item(&db, ItemId::from(req.target_id)).await?, | |
| 138 | + | GalleryTarget::Project => db::gallery_images::count_for_project(&db, ProjectId::from(req.target_id)).await?, | |
| 136 | 139 | }; | |
| 137 | 140 | if count >= db::gallery_images::MAX_GALLERY_IMAGES { | |
| 138 | 141 | return Err(AppError::BadRequest(format!( | |
| @@ -141,7 +144,7 @@ pub(super) async fn gallery_presign( | |||
| 141 | 144 | ))); | |
| 142 | 145 | } | |
| 143 | 146 | ||
| 144 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 147 | + | db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; | |
| 145 | 148 | ||
| 146 | 149 | // Validate the declared size (if any) before signing it into Content-Length. | |
| 147 | 150 | super::validate_declared_upload_size(req.file_size_bytes, file_type, None)?; | |
| @@ -151,7 +154,7 @@ pub(super) async fn gallery_presign( | |||
| 151 | 154 | // uuid also keeps multiple gallery uploads from colliding. | |
| 152 | 155 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 153 | 156 | ||
| 154 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 157 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 155 | 158 | ||
| 156 | 159 | let expires_in = 3600; | |
| 157 | 160 | let upload_url = s3 | |
| @@ -200,15 +203,18 @@ enum GalleryOutcome { | |||
| 200 | 203 | /// POST /api/gallery/confirm — finalize a gallery image upload. | |
| 201 | 204 | #[tracing::instrument(skip_all, name = "storage::gallery_confirm", fields(user_id = %user.id))] | |
| 202 | 205 | pub(super) async fn gallery_confirm( | |
| 203 | - | State(state): State<AppState>, | |
| 206 | + | State(db): State<PgPool>, | |
| 207 | + | State(storage): State<AppStorage>, | |
| 208 | + | State(config): State<Config>, | |
| 209 | + | State(scanning): State<Scanning>, | |
| 204 | 210 | AuthUser(user): AuthUser, | |
| 205 | 211 | Json(req): Json<GalleryConfirmRequest>, | |
| 206 | 212 | ) -> Result<impl IntoResponse> { | |
| 207 | 213 | user.check_not_suspended()?; | |
| 208 | - | let s3 = state.require_s3()?; | |
| 214 | + | let s3 = storage.require_s3()?; | |
| 209 | 215 | let target = GalleryTarget::parse(&req.target_type)?; | |
| 210 | 216 | ||
| 211 | - | require_owned(&state, target, req.target_id, user.id).await?; | |
| 217 | + | require_owned(&db, target, req.target_id, user.id).await?; | |
| 212 | 218 | ||
| 213 | 219 | // Ownership of the staging key is proved inside the confirm transaction below | |
| 214 | 220 | // (after the replay short-circuit) via `pending_uploads` — a `staging/{uuid}` | |
| @@ -224,7 +230,7 @@ pub(super) async fn gallery_confirm( | |||
| 224 | 230 | AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string()) | |
| 225 | 231 | })?; | |
| 226 | 232 | if file_size_bytes as u64 > FileType::Cover.max_size() { | |
| 227 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected").await; | |
| 233 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected").await; | |
| 228 | 234 | return Err(AppError::BadRequest(format!( | |
| 229 | 235 | "File exceeds maximum size of {} MB", | |
| 230 | 236 | FileType::Cover.max_size() / (1024 * 1024) | |
| @@ -233,28 +239,28 @@ pub(super) async fn gallery_confirm( | |||
| 233 | 239 | ||
| 234 | 240 | // Authoritative cap re-check (presign's was best-effort/UX). | |
| 235 | 241 | let count = match target { | |
| 236 | - | GalleryTarget::Item => db::gallery_images::count_for_item(&state.db, ItemId::from(req.target_id)).await?, | |
| 237 | - | GalleryTarget::Project => db::gallery_images::count_for_project(&state.db, ProjectId::from(req.target_id)).await?, | |
| 242 | + | GalleryTarget::Item => db::gallery_images::count_for_item(&db, ItemId::from(req.target_id)).await?, | |
| 243 | + | GalleryTarget::Project => db::gallery_images::count_for_project(&db, ProjectId::from(req.target_id)).await?, | |
| 238 | 244 | }; | |
| 239 | 245 | if count >= db::gallery_images::MAX_GALLERY_IMAGES { | |
| 240 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected").await; | |
| 246 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected").await; | |
| 241 | 247 | return Err(AppError::BadRequest(format!( | |
| 242 | 248 | "Gallery is full (max {} images)", | |
| 243 | 249 | db::gallery_images::MAX_GALLERY_IMAGES | |
| 244 | 250 | ))); | |
| 245 | 251 | } | |
| 246 | 252 | ||
| 247 | - | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await { | |
| 253 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, FileType::Cover, file_size_bytes).await { | |
| 248 | 254 | Ok(max) => max, | |
| 249 | 255 | Err(e) => { | |
| 250 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected").await; | |
| 256 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_upload_rejected").await; | |
| 251 | 257 | return Err(e); | |
| 252 | 258 | } | |
| 253 | 259 | }; | |
| 254 | 260 | ||
| 255 | 261 | let image_url = storage::build_project_image_url( | |
| 256 | 262 | s3.as_ref(), | |
| 257 | - | state.config.cdn_base_url.as_deref(), | |
| 263 | + | config.cdn_base_url.as_deref(), | |
| 258 | 264 | &req.s3_key, | |
| 259 | 265 | ) | |
| 260 | 266 | .await?; | |
| @@ -281,7 +287,7 @@ pub(super) async fn gallery_confirm( | |||
| 281 | 287 | // Returns the existing row on a replayed confirm (idempotent, no re-charge) | |
| 282 | 288 | // or the freshly-inserted id otherwise. | |
| 283 | 289 | let committed: Result<GalleryOutcome> = async { | |
| 284 | - | let mut tx = state.db.begin().await?; | |
| 290 | + | let mut tx = db.begin().await?; | |
| 285 | 291 | // Serialize concurrent confirms for this gallery and re-count INSIDE the | |
| 286 | 292 | // tx, so two inserts that both passed the best-effort pre-check above | |
| 287 | 293 | // can't push it over MAX_GALLERY_IMAGES (Run #14 Storage LOW). A failure | |
| @@ -314,7 +320,7 @@ pub(super) async fn gallery_confirm( | |||
| 314 | 320 | // consumed — so only new rows are gated. A `staging/{uuid}` key has no | |
| 315 | 321 | // entity in its path, so this lookup (not a prefix check) is what stops a | |
| 316 | 322 | // confirm from stealing another user's staging object. | |
| 317 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 323 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 318 | 324 | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 319 | 325 | } | |
| 320 | 326 | let count_in_tx = match target { | |
| @@ -361,16 +367,17 @@ pub(super) async fn gallery_confirm( | |||
| 361 | 367 | } | |
| 362 | 368 | Ok(GalleryOutcome::Inserted(id)) => id, | |
| 363 | 369 | Err(e) => { | |
| 364 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_image_insert_failed").await; | |
| 370 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "gallery_image_insert_failed").await; | |
| 365 | 371 | return Err(e); | |
| 366 | 372 | } | |
| 367 | 373 | }; | |
| 368 | 374 | ||
| 369 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 375 | + | db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; | |
| 370 | 376 | ||
| 371 | 377 | // Scan enqueue AFTER the DB write commits (the chronic-ordering rule). | |
| 372 | 378 | commit_upload( | |
| 373 | - | &state, | |
| 379 | + | &db, | |
| 380 | + | scanning.scanner.as_ref(), | |
| 374 | 381 | CommitTarget::GalleryImage(id.into()), | |
| 375 | 382 | &req.s3_key, | |
| 376 | 383 | FileType::Cover, | |
| @@ -379,7 +386,7 @@ pub(super) async fn gallery_confirm( | |||
| 379 | 386 | ) | |
| 380 | 387 | .await?; | |
| 381 | 388 | ||
| 382 | - | bump_target_cache(&state, target, req.target_id).await; | |
| 389 | + | bump_target_cache(&db, target, req.target_id).await; | |
| 383 | 390 | ||
| 384 | 391 | Ok(Json(GalleryConfirmResponse { success: true, id, image_url, alt })) | |
| 385 | 392 | } | |
| @@ -391,7 +398,7 @@ pub(super) async fn gallery_confirm( | |||
| 391 | 398 | /// DELETE /api/gallery/image/{target_type}/{image_id} — remove one gallery image. | |
| 392 | 399 | #[tracing::instrument(skip_all, name = "storage::gallery_delete", fields(user_id = %user.id, %target_type, %image_id))] | |
| 393 | 400 | pub(super) async fn gallery_delete( | |
| 394 | - | State(state): State<AppState>, | |
| 401 | + | State(db): State<PgPool>, | |
| 395 | 402 | AuthUser(user): AuthUser, | |
| 396 | 403 | Path((target_type, image_id)): Path<(String, ImageId)>, | |
| 397 | 404 | ) -> Result<impl IntoResponse> { | |
| @@ -404,7 +411,7 @@ pub(super) async fn gallery_delete( | |||
| 404 | 411 | // between commit and a post-commit enqueue can't orphan the object with no | |
| 405 | 412 | // durable record (Run #18 Storage B6 — matches delete_version / media_delete). | |
| 406 | 413 | let deleted: Option<db::gallery_images::GalleryImage> = { | |
| 407 | - | let mut tx = state.db.begin().await?; | |
| 414 | + | let mut tx = db.begin().await?; | |
| 408 | 415 | let row = match target { | |
| 409 | 416 | GalleryTarget::Item => db::gallery_images::delete_for_item(&mut *tx, image_id, user.id).await?, | |
| 410 | 417 | GalleryTarget::Project => db::gallery_images::delete_for_project(&mut *tx, image_id, user.id).await?, | |
| @@ -447,39 +454,39 @@ pub struct GalleryReorderRequest { | |||
| 447 | 454 | /// POST /api/gallery/reorder — set gallery display order. | |
| 448 | 455 | #[tracing::instrument(skip_all, name = "storage::gallery_reorder", fields(user_id = %user.id))] | |
| 449 | 456 | pub(super) async fn gallery_reorder( | |
| 450 | - | State(state): State<AppState>, | |
| 457 | + | State(db): State<PgPool>, | |
| 451 | 458 | AuthUser(user): AuthUser, | |
| 452 | 459 | Json(req): Json<GalleryReorderRequest>, | |
| 453 | 460 | ) -> Result<impl IntoResponse> { | |
| 454 | 461 | user.check_not_suspended()?; | |
| 455 | 462 | let target = GalleryTarget::parse(&req.target_type)?; | |
| 456 | - | require_owned(&state, target, req.target_id, user.id).await?; | |
| 463 | + | require_owned(&db, target, req.target_id, user.id).await?; | |
| 457 | 464 | ||
| 458 | 465 | match target { | |
| 459 | 466 | GalleryTarget::Item => { | |
| 460 | - | db::gallery_images::reorder_item(&state.db, ItemId::from(req.target_id), &req.ordered_ids).await? | |
| 467 | + | db::gallery_images::reorder_item(&db, ItemId::from(req.target_id), &req.ordered_ids).await? | |
| 461 | 468 | } | |
| 462 | 469 | GalleryTarget::Project => { | |
| 463 | - | db::gallery_images::reorder_project(&state.db, ProjectId::from(req.target_id), &req.ordered_ids).await? | |
| 470 | + | db::gallery_images::reorder_project(&db, ProjectId::from(req.target_id), &req.ordered_ids).await? | |
| 464 | 471 | } | |
| 465 | 472 | } | |
| 466 | 473 | ||
| 467 | - | bump_target_cache(&state, target, req.target_id).await; | |
| 474 | + | bump_target_cache(&db, target, req.target_id).await; | |
| 468 | 475 | Ok(Json(serde_json::json!({ "success": true }))) | |
| 469 | 476 | } | |
| 470 | 477 | ||
| 471 | 478 | /// Bump the public-page cache generation for the affected project (best-effort). | |
| 472 | - | async fn bump_target_cache(state: &AppState, target: GalleryTarget, target_id: Uuid) { | |
| 479 | + | async fn bump_target_cache(db: &PgPool, target: GalleryTarget, target_id: Uuid) { | |
| 473 | 480 | let project_id = match target { | |
| 474 | 481 | GalleryTarget::Project => Some(ProjectId::from(target_id)), | |
| 475 | - | GalleryTarget::Item => db::items::get_item_by_id(&state.db, ItemId::from(target_id)) | |
| 482 | + | GalleryTarget::Item => db::items::get_item_by_id(db, ItemId::from(target_id)) | |
| 476 | 483 | .await | |
| 477 | 484 | .ok() | |
| 478 | 485 | .flatten() | |
| 479 | 486 | .map(|i| i.project_id), | |
| 480 | 487 | }; | |
| 481 | 488 | if let Some(pid) = project_id | |
| 482 | - | && let Err(e) = db::projects::bump_cache_generation(&state.db, pid).await | |
| 489 | + | && let Err(e) = db::projects::bump_cache_generation(db, pid).await | |
| 483 | 490 | { | |
| 484 | 491 | tracing::warn!(project_id = %pid, error = ?e, "failed to bump cache generation after gallery change"); | |
| 485 | 492 | } |
| @@ -6,13 +6,15 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::AuthUser, | |
| 13 | + | config::Config, | |
| 12 | 14 | db::{self, ItemId, ProjectId}, | |
| 13 | 15 | error::{AppError, Result, ResultExt}, | |
| 14 | 16 | storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 15 | - | AppState, | |
| 17 | + | AppStorage, Scanning, | |
| 16 | 18 | }; | |
| 17 | 19 | ||
| 18 | 20 | use super::{commit_upload, CommitTarget, PresignUploadResponse}; | |
| @@ -61,19 +63,20 @@ pub struct ItemImageConfirmRequest { | |||
| 61 | 63 | /// Requires authentication. User must own the project. | |
| 62 | 64 | #[tracing::instrument(skip_all, name = "storage::project_image_presign", fields(user_id = %user.id))] | |
| 63 | 65 | pub(super) async fn project_image_presign( | |
| 64 | - | State(state): State<AppState>, | |
| 66 | + | State(db): State<PgPool>, | |
| 67 | + | State(storage): State<AppStorage>, | |
| 65 | 68 | AuthUser(user): AuthUser, | |
| 66 | 69 | Json(req): Json<ProjectImagePresignRequest>, | |
| 67 | 70 | ) -> Result<impl IntoResponse> { | |
| 68 | 71 | user.check_not_suspended()?; | |
| 69 | - | let s3 = state.require_s3()?; | |
| 72 | + | let s3 = storage.require_s3()?; | |
| 70 | 73 | ||
| 71 | 74 | let file_type = FileType::Cover; | |
| 72 | 75 | S3Client::validate_content_type(file_type, &req.content_type)?; | |
| 73 | 76 | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 74 | 77 | ||
| 75 | 78 | // Verify user owns the project | |
| 76 | - | let project = db::projects::get_project_by_id(&state.db, req.project_id) | |
| 79 | + | let project = db::projects::get_project_by_id(&db, req.project_id) | |
| 77 | 80 | .await? | |
| 78 | 81 | .ok_or(AppError::NotFound)?; | |
| 79 | 82 | ||
| @@ -82,14 +85,14 @@ pub(super) async fn project_image_presign( | |||
| 82 | 85 | } | |
| 83 | 86 | ||
| 84 | 87 | // Early quota check | |
| 85 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 88 | + | db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; | |
| 86 | 89 | ||
| 87 | 90 | // Staging key (unserved); the scan worker promotes it to the content key and | |
| 88 | 91 | // rebuilds the public `cover_image_url` on a Clean verdict (C1). | |
| 89 | 92 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 90 | 93 | ||
| 91 | 94 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 92 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 95 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 93 | 96 | ||
| 94 | 97 | let expires_in = 3600; | |
| 95 | 98 | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None) | |
| @@ -112,15 +115,18 @@ pub(super) async fn project_image_presign( | |||
| 112 | 115 | /// Requires authentication. User must own the project. | |
| 113 | 116 | #[tracing::instrument(skip_all, name = "storage::project_image_confirm", fields(user_id = %user.id))] | |
| 114 | 117 | pub(super) async fn project_image_confirm( | |
| 115 | - | State(state): State<AppState>, | |
| 118 | + | State(db): State<PgPool>, | |
| 119 | + | State(storage): State<AppStorage>, | |
| 120 | + | State(config): State<Config>, | |
| 121 | + | State(scanning): State<Scanning>, | |
| 116 | 122 | AuthUser(user): AuthUser, | |
| 117 | 123 | Json(req): Json<ProjectImageConfirmRequest>, | |
| 118 | 124 | ) -> Result<impl IntoResponse> { | |
| 119 | 125 | user.check_not_suspended()?; | |
| 120 | - | let s3 = state.require_s3()?; | |
| 126 | + | let s3 = storage.require_s3()?; | |
| 121 | 127 | ||
| 122 | 128 | // Verify user owns the project | |
| 123 | - | let project = db::projects::get_project_by_id(&state.db, req.project_id) | |
| 129 | + | let project = db::projects::get_project_by_id(&db, req.project_id) | |
| 124 | 130 | .await? | |
| 125 | 131 | .ok_or(AppError::NotFound)?; | |
| 126 | 132 | ||
| @@ -133,7 +139,7 @@ pub(super) async fn project_image_confirm( | |||
| 133 | 139 | // presign — not a prefix check. Gate before the size-reject path so an | |
| 134 | 140 | // unowned (at most another user's in-flight) staging object is never enqueued | |
| 135 | 141 | // for deletion. | |
| 136 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 142 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 137 | 143 | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 138 | 144 | } | |
| 139 | 145 | ||
| @@ -143,7 +149,7 @@ pub(super) async fn project_image_confirm( | |||
| 143 | 149 | AppError::BadRequest("Upload not found. Please try uploading again.".to_string()) | |
| 144 | 150 | })?; | |
| 145 | 151 | if file_size_bytes as u64 > FileType::Cover.max_size() { | |
| 146 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 152 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 147 | 153 | return Err(AppError::BadRequest(format!( | |
| 148 | 154 | "File exceeds maximum size of {} MB", | |
| 149 | 155 | FileType::Cover.max_size() / (1024 * 1024) | |
| @@ -156,15 +162,15 @@ pub(super) async fn project_image_confirm( | |||
| 156 | 162 | if let Some(ref cur_url) = project.cover_image_url | |
| 157 | 163 | && let Some(cur_key) = storage::extract_s3_key_from_url( | |
| 158 | 164 | cur_url, | |
| 159 | - | state.config.cdn_base_url.as_deref(), | |
| 165 | + | config.cdn_base_url.as_deref(), | |
| 160 | 166 | Some(s3.bucket()), | |
| 161 | - | state.config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 167 | + | config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 162 | 168 | ) | |
| 163 | 169 | && cur_key == req.s3_key | |
| 164 | 170 | { | |
| 165 | 171 | // Still clear pending_uploads — orphan reaper would otherwise delete | |
| 166 | 172 | // the live S3 object 24h later (Run #7 HIGH-1). | |
| 167 | - | if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await { | |
| 173 | + | if let Err(e) = db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await { | |
| 168 | 174 | tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); | |
| 169 | 175 | } | |
| 170 | 176 | return Ok(Json(ProjectImageConfirmResponse { | |
| @@ -174,10 +180,10 @@ pub(super) async fn project_image_confirm( | |||
| 174 | 180 | } | |
| 175 | 181 | ||
| 176 | 182 | // Enforce tier-based limits | |
| 177 | - | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await { | |
| 183 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, FileType::Cover, file_size_bytes).await { | |
| 178 | 184 | Ok(max) => max, | |
| 179 | 185 | Err(e) => { | |
| 180 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 186 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 181 | 187 | return Err(e); | |
| 182 | 188 | } | |
| 183 | 189 | }; | |
| @@ -191,9 +197,9 @@ pub(super) async fn project_image_confirm( | |||
| 191 | 197 | let old_key_to_delete: Option<String> = if let Some(ref old_url) = project.cover_image_url | |
| 192 | 198 | && let Some(old_key) = storage::extract_s3_key_from_url( | |
| 193 | 199 | old_url, | |
| 194 | - | state.config.cdn_base_url.as_deref(), | |
| 200 | + | config.cdn_base_url.as_deref(), | |
| 195 | 201 | Some(s3.bucket()), | |
| 196 | - | state.config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 202 | + | config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 197 | 203 | ) | |
| 198 | 204 | { | |
| 199 | 205 | match s3.object_size(&old_key).await { | |
| @@ -210,7 +216,7 @@ pub(super) async fn project_image_confirm( | |||
| 210 | 216 | Err(e) => { | |
| 211 | 217 | // S3 probe failed (transient). Refuse to write — letting a probe failure | |
| 212 | 218 | // silently over-count storage on every replace is the bug this branch exists to prevent. | |
| 213 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 219 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 214 | 220 | tracing::warn!(key = %old_key, error = ?e, "S3 probe failed during project image replace"); | |
| 215 | 221 | return Err(AppError::ServiceUnavailable( | |
| 216 | 222 | "Could not verify previous image. Please try again.".to_string(), | |
| @@ -224,7 +230,7 @@ pub(super) async fn project_image_confirm( | |||
| 224 | 230 | // Build permanent URL (async, before the tx). | |
| 225 | 231 | let image_url = storage::build_project_image_url( | |
| 226 | 232 | s3.as_ref(), | |
| 227 | - | state.config.cdn_base_url.as_deref(), | |
| 233 | + | config.cdn_base_url.as_deref(), | |
| 228 | 234 | &req.s3_key, | |
| 229 | 235 | ).await?; | |
| 230 | 236 | ||
| @@ -234,7 +240,7 @@ pub(super) async fn project_image_confirm( | |||
| 234 | 240 | // stays AFTER the commit. `Ok(false)` = ownership filter no-matched (project | |
| 235 | 241 | // deleted/transferred mid-flight); the tx rolled back, nothing charged. | |
| 236 | 242 | let committed: Result<bool> = async { | |
| 237 | - | let mut tx = state.db.begin().await?; | |
| 243 | + | let mut tx = db.begin().await?; | |
| 238 | 244 | db::creator_tiers::try_apply_storage_on( | |
| 239 | 245 | &mut tx, user.id, replace_old_size, file_size_bytes, max_storage, | |
| 240 | 246 | ).await?; | |
| @@ -270,11 +276,11 @@ pub(super) async fn project_image_confirm( | |||
| 270 | 276 | match committed { | |
| 271 | 277 | Err(e) => { | |
| 272 | 278 | // tx rolled back — counter unchanged. Orphan-queue the new key for cleanup. | |
| 273 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "project_image_update_failed").await; | |
| 279 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "project_image_update_failed").await; | |
| 274 | 280 | return Err(e); | |
| 275 | 281 | } | |
| 276 | 282 | Ok(false) => { | |
| 277 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "project_image_update_failed").await; | |
| 283 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "project_image_update_failed").await; | |
| 278 | 284 | return Err(AppError::BadRequest( | |
| 279 | 285 | "Project was modified concurrently. Please try uploading again.".to_string(), | |
| 280 | 286 | )); | |
| @@ -283,14 +289,15 @@ pub(super) async fn project_image_confirm( | |||
| 283 | 289 | } | |
| 284 | 290 | ||
| 285 | 291 | // Clear the pending upload record now that the upload is committed | |
| 286 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 292 | + | db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; | |
| 287 | 293 | ||
| 288 | 294 | // (The old S3 object was enqueued for deletion inside the commit tx above.) | |
| 289 | 295 | ||
| 290 | 296 | // Scan enqueue AFTER the DB write commits (Phase 5 chronic fix — the same | |
| 291 | 297 | // ordering rule that uploads/versions/media follow via `commit_upload`). | |
| 292 | 298 | commit_upload( | |
| 293 | - | &state, | |
| 299 | + | &db, | |
| 300 | + | scanning.scanner.as_ref(), | |
| 294 | 301 | CommitTarget::ProjectImage(req.project_id), | |
| 295 | 302 | &req.s3_key, | |
| 296 | 303 | FileType::Cover, | |
| @@ -299,7 +306,7 @@ pub(super) async fn project_image_confirm( | |||
| 299 | 306 | ).await?; | |
| 300 | 307 | ||
| 301 | 308 | // Bump cache | |
| 302 | - | db::projects::bump_cache_generation(&state.db, req.project_id).await?; | |
| 309 | + | db::projects::bump_cache_generation(&db, req.project_id).await?; | |
| 303 | 310 | ||
| 304 | 311 | tracing::info!( | |
| 305 | 312 | "Project image confirmed: project={}, key={}, size={}", | |
| @@ -321,19 +328,20 @@ pub(super) async fn project_image_confirm( | |||
| 321 | 328 | /// Requires authentication. User must own the item. | |
| 322 | 329 | #[tracing::instrument(skip_all, name = "storage::item_image_presign", fields(user_id = %user.id))] | |
| 323 | 330 | pub(super) async fn item_image_presign( | |
| 324 | - | State(state): State<AppState>, | |
| 331 | + | State(db): State<PgPool>, | |
| 332 | + | State(storage): State<AppStorage>, | |
| 325 | 333 | AuthUser(user): AuthUser, | |
| 326 | 334 | Json(req): Json<ItemImagePresignRequest>, | |
| 327 | 335 | ) -> Result<impl IntoResponse> { | |
| 328 | 336 | user.check_not_suspended()?; | |
| 329 | - | let s3 = state.require_s3()?; | |
| 337 | + | let s3 = storage.require_s3()?; | |
| 330 | 338 | ||
| 331 | 339 | let file_type = FileType::Cover; | |
| 332 | 340 | S3Client::validate_content_type(file_type, &req.content_type)?; | |
| 333 | 341 | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 334 | 342 | ||
| 335 | 343 | // Verify user owns the item | |
| 336 | - | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 344 | + | let owner = db::items::get_item_owner(&db, req.item_id) | |
| 337 | 345 | .await? | |
| 338 | 346 | .ok_or(AppError::NotFound)?; | |
| 339 | 347 | ||
| @@ -342,14 +350,14 @@ pub(super) async fn item_image_presign( | |||
| 342 | 350 | } | |
| 343 | 351 | ||
| 344 | 352 | // Early quota check | |
| 345 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 353 | + | db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; | |
| 346 | 354 | ||
| 347 | 355 | // Staging key (unserved); the scan worker promotes it to the content key and | |
| 348 | 356 | // rebuilds the public `cover_image_url` on a Clean verdict (C1). | |
| 349 | 357 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 350 | 358 | ||
| 351 | 359 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 352 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 360 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 353 | 361 | ||
| 354 | 362 | let expires_in = 3600; | |
| 355 | 363 | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), None) | |
| @@ -372,15 +380,18 @@ pub(super) async fn item_image_presign( | |||
| 372 | 380 | /// Requires authentication. User must own the item. | |
| 373 | 381 | #[tracing::instrument(skip_all, name = "storage::item_image_confirm", fields(user_id = %user.id))] | |
| 374 | 382 | pub(super) async fn item_image_confirm( | |
| 375 | - | State(state): State<AppState>, | |
| 383 | + | State(db): State<PgPool>, | |
| 384 | + | State(storage): State<AppStorage>, | |
| 385 | + | State(config): State<Config>, | |
| 386 | + | State(scanning): State<Scanning>, | |
| 376 | 387 | AuthUser(user): AuthUser, | |
| 377 | 388 | Json(req): Json<ItemImageConfirmRequest>, | |
| 378 | 389 | ) -> Result<impl IntoResponse> { | |
| 379 | 390 | user.check_not_suspended()?; | |
| 380 | - | let s3 = state.require_s3()?; | |
| 391 | + | let s3 = storage.require_s3()?; | |
| 381 | 392 | ||
| 382 | 393 | // Verify user owns the item | |
| 383 | - | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 394 | + | let owner = db::items::get_item_owner(&db, req.item_id) | |
| 384 | 395 | .await? | |
| 385 | 396 | .ok_or(AppError::NotFound)?; | |
| 386 | 397 | ||
| @@ -393,7 +404,7 @@ pub(super) async fn item_image_confirm( | |||
| 393 | 404 | // was minted for THIS user's upload flow — is proved via the `pending_uploads` | |
| 394 | 405 | // row recorded at presign, not a prefix check. Gate before the size-reject | |
| 395 | 406 | // path so an unowned staging object is never enqueued for deletion. | |
| 396 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 407 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 397 | 408 | return Err(AppError::BadRequest( | |
| 398 | 409 | "Invalid upload key".to_string(), | |
| 399 | 410 | )); | |
| @@ -405,7 +416,7 @@ pub(super) async fn item_image_confirm( | |||
| 405 | 416 | AppError::BadRequest("Upload not found. Please try uploading again.".to_string()) | |
| 406 | 417 | })?; | |
| 407 | 418 | if file_size_bytes as u64 > FileType::Cover.max_size() { | |
| 408 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 419 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 409 | 420 | return Err(AppError::BadRequest(format!( | |
| 410 | 421 | "File exceeds maximum size of {} MB", | |
| 411 | 422 | FileType::Cover.max_size() / (1024 * 1024) | |
| @@ -416,13 +427,13 @@ pub(super) async fn item_image_confirm( | |||
| 416 | 427 | // the tier/cap check — a creator at their storage cap must still be able to | |
| 417 | 428 | // re-confirm a cover they already own (Run 9; matches project_image_confirm's | |
| 418 | 429 | // ordering). Otherwise capture the existing cover for atomic replacement below. | |
| 419 | - | let existing_item = db::items::get_item_by_id(&state.db, req.item_id).await?; | |
| 430 | + | let existing_item = db::items::get_item_by_id(&db, req.item_id).await?; | |
| 420 | 431 | if let Some(ref item) = existing_item | |
| 421 | 432 | && item.cover_s3_key.as_deref() == Some(&req.s3_key) | |
| 422 | 433 | { | |
| 423 | 434 | // Still clear pending_uploads — orphan reaper would otherwise delete | |
| 424 | 435 | // the live S3 object 24h later (Run #7 HIGH-1). | |
| 425 | - | if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await { | |
| 436 | + | if let Err(e) = db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await { | |
| 426 | 437 | tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); | |
| 427 | 438 | } | |
| 428 | 439 | return Ok(Json(super::images::ProjectImageConfirmResponse { | |
| @@ -432,10 +443,10 @@ pub(super) async fn item_image_confirm( | |||
| 432 | 443 | } | |
| 433 | 444 | ||
| 434 | 445 | // Enforce tier-based limits | |
| 435 | - | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await { | |
| 446 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, FileType::Cover, file_size_bytes).await { | |
| 436 | 447 | Ok(max) => max, | |
| 437 | 448 | Err(e) => { | |
| 438 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 449 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "image_upload_rejected").await; | |
| 439 | 450 | return Err(e); | |
| 440 | 451 | } | |
| 441 | 452 | }; | |
| @@ -458,7 +469,7 @@ pub(super) async fn item_image_confirm( | |||
| 458 | 469 | ||
| 459 | 470 | let image_url = storage::build_project_image_url( | |
| 460 | 471 | s3.as_ref(), | |
| 461 | - | state.config.cdn_base_url.as_deref(), | |
| 472 | + | config.cdn_base_url.as_deref(), | |
| 462 | 473 | &req.s3_key, | |
| 463 | 474 | ).await?; | |
| 464 | 475 | ||
| @@ -468,7 +479,7 @@ pub(super) async fn item_image_confirm( | |||
| 468 | 479 | // `commit_upload` stays AFTER the commit. `Ok(false)` = ownership filter | |
| 469 | 480 | // no-matched (item deleted/moved mid-flight); the tx rolled back, nothing charged. | |
| 470 | 481 | let committed: Result<bool> = async { | |
| 471 | - | let mut tx = state.db.begin().await?; | |
| 482 | + | let mut tx = db.begin().await?; | |
| 472 | 483 | db::creator_tiers::try_apply_storage_on( | |
| 473 | 484 | &mut tx, user.id, replace_old_size, file_size_bytes, max_storage, | |
| 474 | 485 | ).await?; | |
| @@ -499,11 +510,11 @@ pub(super) async fn item_image_confirm( | |||
| 499 | 510 | ||
| 500 | 511 | match committed { | |
| 501 | 512 | Err(e) => { | |
| 502 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_image_update_failed").await; | |
| 513 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_image_update_failed").await; | |
| 503 | 514 | return Err(e); | |
| 504 | 515 | } | |
| 505 | 516 | Ok(false) => { | |
| 506 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_image_update_failed").await; | |
| 517 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_image_update_failed").await; | |
| 507 | 518 | return Err(AppError::BadRequest( | |
| 508 | 519 | "Item was modified concurrently. Please try uploading again.".to_string(), | |
| 509 | 520 | )); | |
| @@ -511,14 +522,15 @@ pub(super) async fn item_image_confirm( | |||
| 511 | 522 | Ok(true) => {} | |
| 512 | 523 | } | |
| 513 | 524 | ||
| 514 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 525 | + | db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; | |
| 515 | 526 | ||
| 516 | 527 | // (The old cover was enqueued for deletion inside the commit tx above.) | |
| 517 | 528 | ||
| 518 | 529 | // Scan enqueue + scan_status flip AFTER the DB write — same ordering rule | |
| 519 | 530 | // as uploads/versions/media. The Run #6 audit caught this same bug here. | |
| 520 | 531 | commit_upload( | |
| 521 | - | &state, | |
| 532 | + | &db, | |
| 533 | + | scanning.scanner.as_ref(), | |
| 522 | 534 | CommitTarget::ItemImage(req.item_id), | |
| 523 | 535 | &req.s3_key, | |
| 524 | 536 | FileType::Cover, | |
| @@ -527,8 +539,8 @@ pub(super) async fn item_image_confirm( | |||
| 527 | 539 | ).await?; | |
| 528 | 540 | ||
| 529 | 541 | // Bump project cache | |
| 530 | - | if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? | |
| 531 | - | && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await | |
| 542 | + | if let Some(item) = db::items::get_item_by_id(&db, req.item_id).await? | |
| 543 | + | && let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await | |
| 532 | 544 | { | |
| 533 | 545 | tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after image upload"); | |
| 534 | 546 | } |
| @@ -11,13 +11,15 @@ use axum::{ | |||
| 11 | 11 | Json, | |
| 12 | 12 | }; | |
| 13 | 13 | use serde::{Deserialize, Serialize}; | |
| 14 | + | use sqlx::PgPool; | |
| 14 | 15 | ||
| 15 | 16 | use crate::{ | |
| 16 | 17 | auth::AuthUser, | |
| 18 | + | config::Config, | |
| 17 | 19 | db::{self, MediaFileId}, | |
| 18 | 20 | error::{AppError, Result, ResultExt}, | |
| 19 | 21 | storage::{sanitize_filename, sanitize_folder, FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 20 | - | AppState, | |
| 22 | + | AppStorage, Scanning, | |
| 21 | 23 | }; | |
| 22 | 24 | ||
| 23 | 25 | use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse}; | |
| @@ -134,12 +136,13 @@ fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse { | |||
| 134 | 136 | /// POST /api/media/presign | |
| 135 | 137 | #[tracing::instrument(skip_all, name = "media::presign", fields(user_id = %user.id))] | |
| 136 | 138 | pub(super) async fn media_presign( | |
| 137 | - | State(state): State<AppState>, | |
| 139 | + | State(db): State<PgPool>, | |
| 140 | + | State(storage): State<AppStorage>, | |
| 138 | 141 | AuthUser(user): AuthUser, | |
| 139 | 142 | Json(req): Json<MediaPresignRequest>, | |
| 140 | 143 | ) -> Result<impl IntoResponse> { | |
| 141 | 144 | user.check_not_suspended()?; | |
| 142 | - | let s3 = state.require_s3()?; | |
| 145 | + | let s3 = storage.require_s3()?; | |
| 143 | 146 | ||
| 144 | 147 | let (media_type, file_type) = classify_media(&req.content_type)?; | |
| 145 | 148 | let _ = media_type; // used at confirm time | |
| @@ -157,7 +160,7 @@ pub(super) async fn media_presign( | |||
| 157 | 160 | } | |
| 158 | 161 | ||
| 159 | 162 | // Early quota check — images bypass tier, video requires BigFiles+ | |
| 160 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 163 | + | db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; | |
| 161 | 164 | ||
| 162 | 165 | // Validate the declared size against the static per-type cap AND the user's | |
| 163 | 166 | // tier per-file cap before signing it into Content-Length, so an oversize | |
| @@ -165,7 +168,7 @@ pub(super) async fn media_presign( | |||
| 165 | 168 | // and only caught at confirm. `get_effective_max_file_bytes` returns None for | |
| 166 | 169 | // images (they bypass the tier cap) and the tier limit for video. | |
| 167 | 170 | let max_file_bytes = | |
| 168 | - | db::creator_tiers::get_effective_max_file_bytes(&state.db, user.id, file_type).await?; | |
| 171 | + | db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?; | |
| 169 | 172 | super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?; | |
| 170 | 173 | ||
| 171 | 174 | // Filename uniqueness is enforced at confirm time by the | |
| @@ -185,7 +188,7 @@ pub(super) async fn media_presign( | |||
| 185 | 188 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 186 | 189 | ||
| 187 | 190 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 188 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 191 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 189 | 192 | ||
| 190 | 193 | let expires_in = 3600; | |
| 191 | 194 | let upload_url = s3 | |
| @@ -207,12 +210,14 @@ pub(super) async fn media_presign( | |||
| 207 | 210 | /// POST /api/media/confirm | |
| 208 | 211 | #[tracing::instrument(skip_all, name = "media::confirm", fields(user_id = %user.id))] | |
| 209 | 212 | pub(super) async fn media_confirm( | |
| 210 | - | State(state): State<AppState>, | |
| 213 | + | State(db): State<PgPool>, | |
| 214 | + | State(storage): State<AppStorage>, | |
| 215 | + | State(scanning): State<Scanning>, | |
| 211 | 216 | AuthUser(user): AuthUser, | |
| 212 | 217 | Json(req): Json<MediaConfirmRequest>, | |
| 213 | 218 | ) -> Result<impl IntoResponse> { | |
| 214 | 219 | user.check_not_suspended()?; | |
| 215 | - | let s3 = state.require_s3()?; | |
| 220 | + | let s3 = storage.require_s3()?; | |
| 216 | 221 | ||
| 217 | 222 | let (media_type, file_type) = classify_media(&req.content_type)?; | |
| 218 | 223 | ||
| @@ -225,7 +230,7 @@ pub(super) async fn media_confirm( | |||
| 225 | 230 | // not a prefix check. Gate before the sniff/size-reject paths so an unowned | |
| 226 | 231 | // (at most another user's in-flight) staging object is never enqueued for | |
| 227 | 232 | // deletion. | |
| 228 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 233 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 229 | 234 | return Err(AppError::BadRequest( | |
| 230 | 235 | "Invalid upload key".to_string(), | |
| 231 | 236 | )); | |
| @@ -243,7 +248,7 @@ pub(super) async fn media_confirm( | |||
| 243 | 248 | AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string()) | |
| 244 | 249 | })?; | |
| 245 | 250 | if file_size_bytes as u64 > file_type.max_size() { | |
| 246 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected").await; | |
| 251 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected").await; | |
| 247 | 252 | return Err(AppError::BadRequest(format!( | |
| 248 | 253 | "File exceeds maximum size of {} MB", | |
| 249 | 254 | file_type.max_size() / (1024 * 1024) | |
| @@ -279,7 +284,7 @@ pub(super) async fn media_confirm( | |||
| 279 | 284 | _ => false, | |
| 280 | 285 | }; | |
| 281 | 286 | if mismatch { | |
| 282 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "media_content_type_mismatch").await; | |
| 287 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "media_content_type_mismatch").await; | |
| 283 | 288 | let sniffed = match detected { | |
| 284 | 289 | Some(infer::MatcherType::Image) => "image", | |
| 285 | 290 | Some(infer::MatcherType::Video) => "video", | |
| @@ -293,13 +298,13 @@ pub(super) async fn media_confirm( | |||
| 293 | 298 | ||
| 294 | 299 | // Tier enforcement | |
| 295 | 300 | let max_storage = match db::creator_tiers::check_upload_allowed( | |
| 296 | - | &state.db, user.id, file_type, file_size_bytes, | |
| 301 | + | &db, user.id, file_type, file_size_bytes, | |
| 297 | 302 | ) | |
| 298 | 303 | .await | |
| 299 | 304 | { | |
| 300 | 305 | Ok(max) => max, | |
| 301 | 306 | Err(e) => { | |
| 302 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected").await; | |
| 307 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected").await; | |
| 303 | 308 | return Err(e); | |
| 304 | 309 | } | |
| 305 | 310 | }; | |
| @@ -331,7 +336,7 @@ pub(super) async fn media_confirm( | |||
| 331 | 336 | // The unique index on (user_id, folder, filename) raises 23505 inside the | |
| 332 | 337 | // tx; we catch the typed error after rollback and report a clean message. | |
| 333 | 338 | let tx_result: Result<db::DbMediaFile> = async { | |
| 334 | - | let mut tx = state.db.begin().await?; | |
| 339 | + | let mut tx = db.begin().await?; | |
| 335 | 340 | db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage).await?; | |
| 336 | 341 | db::pending_uploads::remove_pending_upload(&mut *tx, user.id, &req.s3_key, "main").await?; | |
| 337 | 342 | let row = db::media_files::create( | |
| @@ -377,7 +382,7 @@ pub(super) async fn media_confirm( | |||
| 377 | 382 | } | |
| 378 | 383 | // Any other failure: the tx rolled back and no row references this | |
| 379 | 384 | // freshly-uploaded object, so it's a genuine orphan — clean it up. | |
| 380 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected").await; | |
| 385 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "media_upload_rejected").await; | |
| 381 | 386 | return Err(e); | |
| 382 | 387 | } | |
| 383 | 388 | }; | |
| @@ -386,7 +391,8 @@ pub(super) async fn media_confirm( | |||
| 386 | 391 | // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner | |
| 387 | 392 | // dev/test environment doesn't leave the row Pending forever. | |
| 388 | 393 | let scan_status = commit_upload( | |
| 389 | - | &state, | |
| 394 | + | &db, | |
| 395 | + | scanning.scanner.as_ref(), | |
| 390 | 396 | CommitTarget::Media(inserted.id), | |
| 391 | 397 | &req.s3_key, | |
| 392 | 398 | file_type, | |
| @@ -412,7 +418,8 @@ pub(super) async fn media_confirm( | |||
| 412 | 418 | /// GET /api/media?folder={folder} | |
| 413 | 419 | #[tracing::instrument(skip_all, name = "media::list", fields(user_id = %user.id))] | |
| 414 | 420 | pub(super) async fn media_list( | |
| 415 | - | State(state): State<AppState>, | |
| 421 | + | State(db): State<PgPool>, | |
| 422 | + | State(config): State<Config>, | |
| 416 | 423 | AuthUser(user): AuthUser, | |
| 417 | 424 | Query(query): Query<MediaListQuery>, | |
| 418 | 425 | ) -> Result<impl IntoResponse> { | |
| @@ -420,7 +427,7 @@ pub(super) async fn media_list( | |||
| 420 | 427 | // without CDN config still render plausible URLs. In production a | |
| 421 | 428 | // missing `cdn_base_url` is an operator-side misconfiguration; we log | |
| 422 | 429 | // a WARN once per process so it surfaces without blocking the request. | |
| 423 | - | let cdn_base = if let Some(base) = state.config.cdn_base_url.as_deref() { | |
| 430 | + | let cdn_base = if let Some(base) = config.cdn_base_url.as_deref() { | |
| 424 | 431 | base | |
| 425 | 432 | } else { | |
| 426 | 433 | static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); | |
| @@ -433,13 +440,13 @@ pub(super) async fn media_list( | |||
| 433 | 440 | }; | |
| 434 | 441 | ||
| 435 | 442 | let files = db::media_files::list_by_user_folder( | |
| 436 | - | &state.db, | |
| 443 | + | &db, | |
| 437 | 444 | user.id, | |
| 438 | 445 | query.folder.as_deref(), | |
| 439 | 446 | ) | |
| 440 | 447 | .await?; | |
| 441 | 448 | ||
| 442 | - | let folders = db::media_files::list_folders(&state.db, user.id).await?; | |
| 449 | + | let folders = db::media_files::list_folders(&db, user.id).await?; | |
| 443 | 450 | ||
| 444 | 451 | let file_responses: Vec<MediaFileResponse> = files | |
| 445 | 452 | .iter() | |
| @@ -457,10 +464,10 @@ pub(super) async fn media_list( | |||
| 457 | 464 | /// GET /api/media/folders | |
| 458 | 465 | #[tracing::instrument(skip_all, name = "media::folders", fields(user_id = %user.id))] | |
| 459 | 466 | pub(super) async fn media_folders( | |
| 460 | - | State(state): State<AppState>, | |
| 467 | + | State(db): State<PgPool>, | |
| 461 | 468 | AuthUser(user): AuthUser, | |
| 462 | 469 | ) -> Result<impl IntoResponse> { | |
| 463 | - | let folders = db::media_files::list_folders(&state.db, user.id).await?; | |
| 470 | + | let folders = db::media_files::list_folders(&db, user.id).await?; | |
| 464 | 471 | Ok(Json(MediaFoldersResponse { folders })) | |
| 465 | 472 | } | |
| 466 | 473 | ||
| @@ -469,16 +476,17 @@ pub(super) async fn media_folders( | |||
| 469 | 476 | /// DELETE /api/media/{id} | |
| 470 | 477 | #[tracing::instrument(skip_all, name = "media::delete", fields(user_id = %user.id, media_id = %id))] | |
| 471 | 478 | pub(super) async fn media_delete( | |
| 472 | - | State(state): State<AppState>, | |
| 479 | + | State(db): State<PgPool>, | |
| 480 | + | State(storage): State<AppStorage>, | |
| 473 | 481 | AuthUser(user): AuthUser, | |
| 474 | 482 | Path(id): Path<MediaFileId>, | |
| 475 | 483 | ) -> Result<impl IntoResponse> { | |
| 476 | 484 | user.check_not_suspended()?; | |
| 477 | 485 | // Require S3 to be configured, but the actual delete goes through the | |
| 478 | 486 | // durable queue (the sanctioned deletion path) rather than a direct call. | |
| 479 | - | state.require_s3()?; | |
| 487 | + | storage.require_s3()?; | |
| 480 | 488 | ||
| 481 | - | let file = db::media_files::get_by_id(&state.db, id) | |
| 489 | + | let file = db::media_files::get_by_id(&db, id) | |
| 482 | 490 | .await? | |
| 483 | 491 | .ok_or(AppError::NotFound)?; | |
| 484 | 492 | ||
| @@ -503,7 +511,7 @@ pub(super) async fn media_delete( | |||
| 503 | 511 | // with no durable record (Run #18 Storage B6 — the same in-tx ordering | |
| 504 | 512 | // delete_version adopted). The refund + enqueue use the DELETE's own returned | |
| 505 | 513 | // row (`deleted`), not the pre-tx `get_by_id` read. | |
| 506 | - | let mut tx = state.db.begin().await?; | |
| 514 | + | let mut tx = db.begin().await?; | |
| 507 | 515 | let deleted = db::media_files::delete(&mut *tx, id, user.id).await?; | |
| 508 | 516 | if let Some(ref row) = deleted { | |
| 509 | 517 | db::creator_tiers::decrement_storage_used(&mut *tx, user.id, row.file_size_bytes).await?; |
| @@ -7,19 +7,24 @@ pub(crate) mod media; | |||
| 7 | 7 | mod uploads; | |
| 8 | 8 | mod versions; | |
| 9 | 9 | ||
| 10 | + | use std::sync::Arc; | |
| 11 | + | ||
| 10 | 12 | use axum::routing::get; | |
| 11 | 13 | use serde::Serialize; | |
| 14 | + | use sqlx::PgPool; | |
| 12 | 15 | use tower_governor::GovernorLayer; | |
| 13 | 16 | use uuid::Uuid; | |
| 14 | 17 | ||
| 15 | 18 | use crate::{ | |
| 19 | + | config::Config, | |
| 16 | 20 | constants, | |
| 17 | 21 | csrf::{delete_csrf, post_csrf, CsrfRouter}, | |
| 18 | 22 | db, | |
| 19 | 23 | db::scan_jobs::ScanTargetKind, | |
| 20 | 24 | error::{AppError, Result}, | |
| 25 | + | scanning::ScanPipeline, | |
| 21 | 26 | storage::FileType, | |
| 22 | - | AppState, | |
| 27 | + | AppState, AppStorage, | |
| 23 | 28 | }; | |
| 24 | 29 | ||
| 25 | 30 | /// Validate a client-declared upload size before it is signed into the | |
| @@ -261,7 +266,8 @@ impl CommitTarget { | |||
| 261 | 266 | fields(kind = ?target.kind(), target_id = %target.target_uuid(), %user_id, file_size_bytes, scan_status = tracing::field::Empty) | |
| 262 | 267 | )] | |
| 263 | 268 | pub(crate) async fn commit_upload( | |
| 264 | - | state: &AppState, | |
| 269 | + | db: &PgPool, | |
| 270 | + | scanner: Option<&Arc<ScanPipeline>>, | |
| 265 | 271 | target: CommitTarget, | |
| 266 | 272 | s3_key: &str, | |
| 267 | 273 | file_type: FileType, | |
| @@ -269,7 +275,8 @@ pub(crate) async fn commit_upload( | |||
| 269 | 275 | file_size_bytes: i64, | |
| 270 | 276 | ) -> Result<db::FileScanStatus> { | |
| 271 | 277 | let scan_status = enqueue_scan_for( | |
| 272 | - | state, | |
| 278 | + | db, | |
| 279 | + | scanner, | |
| 273 | 280 | target.kind(), | |
| 274 | 281 | target.target_uuid(), | |
| 275 | 282 | s3_key, | |
| @@ -280,13 +287,13 @@ pub(crate) async fn commit_upload( | |||
| 280 | 287 | .await?; | |
| 281 | 288 | match target { | |
| 282 | 289 | CommitTarget::Item(id) => { | |
| 283 | - | db::scanning::update_item_scan_status(&state.db, id, scan_status).await?; | |
| 290 | + | db::scanning::update_item_scan_status(db, id, scan_status).await?; | |
| 284 | 291 | } | |
| 285 | 292 | CommitTarget::Version(id) => { | |
| 286 | - | db::scanning::update_version_scan_status(&state.db, id, scan_status).await?; | |
| 293 | + | db::scanning::update_version_scan_status(db, id, scan_status).await?; | |
| 287 | 294 | } | |
| 288 | 295 | CommitTarget::Media(id) => { | |
| 289 | - | db::scanning::update_media_file_scan_status(&state.db, id, scan_status).await?; | |
| 296 | + | db::scanning::update_media_file_scan_status(db, id, scan_status).await?; | |
| 290 | 297 | } | |
| 291 | 298 | CommitTarget::ItemImage(_) | |
| 292 | 299 | | CommitTarget::ProjectImage(_) | |
| @@ -318,7 +325,8 @@ pub(crate) async fn commit_upload( | |||
| 318 | 325 | fields(kind = ?target.kind(), target_id = %target.target_uuid(), %user_id, file_size_bytes) | |
| 319 | 326 | )] | |
| 320 | 327 | pub(crate) async fn commit_rescan( | |
| 321 | - | state: &AppState, | |
| 328 | + | db: &PgPool, | |
| 329 | + | scanner: Option<&Arc<ScanPipeline>>, | |
| 322 | 330 | target: CommitTarget, | |
| 323 | 331 | s3_key: &str, | |
| 324 | 332 | file_type: FileType, | |
| @@ -326,7 +334,8 @@ pub(crate) async fn commit_rescan( | |||
| 326 | 334 | file_size_bytes: i64, | |
| 327 | 335 | ) -> Result<db::FileScanStatus> { | |
| 328 | 336 | enqueue_scan_for( | |
| 329 | - | state, | |
| 337 | + | db, | |
| 338 | + | scanner, | |
| 330 | 339 | target.kind(), | |
| 331 | 340 | target.target_uuid(), | |
| 332 | 341 | s3_key, | |
| @@ -338,13 +347,13 @@ pub(crate) async fn commit_rescan( | |||
| 338 | 347 | let pending = db::FileScanStatus::Pending; | |
| 339 | 348 | match target { | |
| 340 | 349 | CommitTarget::Item(id) => { | |
| 341 | - | db::scanning::update_item_scan_status(&state.db, id, pending).await?; | |
| 350 | + | db::scanning::update_item_scan_status(db, id, pending).await?; | |
| 342 | 351 | } | |
| 343 | 352 | CommitTarget::Version(id) => { | |
| 344 | - | db::scanning::update_version_scan_status(&state.db, id, pending).await?; | |
| 353 | + | db::scanning::update_version_scan_status(db, id, pending).await?; | |
| 345 | 354 | } | |
| 346 | 355 | CommitTarget::Media(id) => { | |
| 347 | - | db::scanning::update_media_file_scan_status(&state.db, id, pending).await?; | |
| 356 | + | db::scanning::update_media_file_scan_status(db, id, pending).await?; | |
| 348 | 357 | } | |
| 349 | 358 | CommitTarget::ItemImage(_) | |
| 350 | 359 | | CommitTarget::ProjectImage(_) | |
| @@ -368,11 +377,16 @@ pub(crate) async fn commit_rescan( | |||
| 368 | 377 | /// Idempotent: a file already at a `{owner}/c/…` content key is skipped, so a | |
| 369 | 378 | /// bulk approve over a mixed set (some already promoted) is safe. | |
| 370 | 379 | #[tracing::instrument(skip_all, name = "storage::commit_promote_item", fields(%item_id))] | |
| 371 | - | pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) -> Result<()> { | |
| 372 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 380 | + | pub(crate) async fn commit_promote_item( | |
| 381 | + | db: &PgPool, | |
| 382 | + | storage: &AppStorage, | |
| 383 | + | config: &Config, | |
| 384 | + | item_id: db::ItemId, | |
| 385 | + | ) -> Result<()> { | |
| 386 | + | let item = db::items::get_item_by_id(db, item_id) | |
| 373 | 387 | .await? | |
| 374 | 388 | .ok_or(AppError::NotFound)?; | |
| 375 | - | let owner = db::items::get_item_owner(&state.db, item_id) | |
| 389 | + | let owner = db::items::get_item_owner(db, item_id) | |
| 376 | 390 | .await? | |
| 377 | 391 | .ok_or(AppError::NotFound)?; | |
| 378 | 392 | // Only the files still at a staging key need a copy; skip a file that is | |
| @@ -392,9 +406,9 @@ pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) - | |||
| 392 | 406 | }) | |
| 393 | 407 | .collect(); | |
| 394 | 408 | if !staged.is_empty() { | |
| 395 | - | let s3 = state.require_s3()?; | |
| 409 | + | let s3 = storage.require_s3()?; | |
| 396 | 410 | for (staging_key, file_type) in staged { | |
| 397 | - | let sha256 = db::scanning::latest_sha256_by_key(&state.db, &staging_key) | |
| 411 | + | let sha256 = db::scanning::latest_sha256_by_key(db, &staging_key) | |
| 398 | 412 | .await? | |
| 399 | 413 | .ok_or_else(|| { | |
| 400 | 414 | AppError::Storage(format!( | |
| @@ -402,12 +416,12 @@ pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) - | |||
| 402 | 416 | )) | |
| 403 | 417 | })?; | |
| 404 | 418 | crate::scanning::promote_staging_to_content( | |
| 405 | - | &state.db, | |
| 419 | + | db, | |
| 406 | 420 | s3.as_ref(), | |
| 407 | 421 | // Item audio is a gated Main-bucket kind; the public backend is | |
| 408 | 422 | // unused here but passed for signature consistency. | |
| 409 | - | state.storage.public_s3.as_deref(), | |
| 410 | - | state.config.cdn_base_url.as_deref(), | |
| 423 | + | storage.public_s3.as_deref(), | |
| 424 | + | config.cdn_base_url.as_deref(), | |
| 411 | 425 | ScanTargetKind::Item, | |
| 412 | 426 | file_type, | |
| 413 | 427 | item_id.into(), | |
| @@ -422,7 +436,7 @@ pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) - | |||
| 422 | 436 | // `promote_gated` already set `scan_status = 'clean'` per promoted file; this | |
| 423 | 437 | // also covers the no-staged-file case (all already content-keyed) so the | |
| 424 | 438 | // admin action still lands the item Clean. | |
| 425 | - | db::scanning::update_item_scan_status(&state.db, item_id, db::FileScanStatus::Clean).await?; | |
| 439 | + | db::scanning::update_item_scan_status(db, item_id, db::FileScanStatus::Clean).await?; | |
| 426 | 440 | Ok(()) | |
| 427 | 441 | } | |
| 428 | 442 | ||
| @@ -430,11 +444,16 @@ pub(crate) async fn commit_promote_item(state: &AppState, item_id: db::ItemId) - | |||
| 430 | 444 | /// then mark the version Clean. See [`commit_promote_item`] for why the copy must | |
| 431 | 445 | /// happen at approve time and why it is factored here. | |
| 432 | 446 | #[tracing::instrument(skip_all, name = "storage::commit_promote_version", fields(%version_id))] | |
| 433 | - | pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::VersionId) -> Result<()> { | |
| 434 | - | let version = db::versions::get_version_by_id(&state.db, version_id) | |
| 447 | + | pub(crate) async fn commit_promote_version( | |
| 448 | + | db: &PgPool, | |
| 449 | + | storage: &AppStorage, | |
| 450 | + | config: &Config, | |
| 451 | + | version_id: db::VersionId, | |
| 452 | + | ) -> Result<()> { | |
| 453 | + | let version = db::versions::get_version_by_id(db, version_id) | |
| 435 | 454 | .await? | |
| 436 | 455 | .ok_or(AppError::NotFound)?; | |
| 437 | - | let owner = db::items::get_item_owner(&state.db, version.item_id) | |
| 456 | + | let owner = db::items::get_item_owner(db, version.item_id) | |
| 438 | 457 | .await? | |
| 439 | 458 | .ok_or(AppError::NotFound)?; | |
| 440 | 459 | // `require_s3` only when there is a staging file to copy — a held row with no | |
| @@ -443,8 +462,8 @@ pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::Ver | |||
| 443 | 462 | if let Some(staging_key) = version.s3_key.clone() | |
| 444 | 463 | && staging_key.starts_with("staging/") | |
| 445 | 464 | { | |
| 446 | - | let s3 = state.require_s3()?; | |
| 447 | - | let sha256 = db::scanning::latest_sha256_by_key(&state.db, &staging_key) | |
| 465 | + | let s3 = storage.require_s3()?; | |
| 466 | + | let sha256 = db::scanning::latest_sha256_by_key(db, &staging_key) | |
| 448 | 467 | .await? | |
| 449 | 468 | .ok_or_else(|| { | |
| 450 | 469 | AppError::Storage(format!( | |
| @@ -452,11 +471,11 @@ pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::Ver | |||
| 452 | 471 | )) | |
| 453 | 472 | })?; | |
| 454 | 473 | crate::scanning::promote_staging_to_content( | |
| 455 | - | &state.db, | |
| 474 | + | db, | |
| 456 | 475 | s3.as_ref(), | |
| 457 | 476 | // Version downloads are a gated Main-bucket kind; public backend unused. | |
| 458 | - | state.storage.public_s3.as_deref(), | |
| 459 | - | state.config.cdn_base_url.as_deref(), | |
| 477 | + | storage.public_s3.as_deref(), | |
| 478 | + | config.cdn_base_url.as_deref(), | |
| 460 | 479 | ScanTargetKind::Version, | |
| 461 | 480 | FileType::Download, | |
| 462 | 481 | version_id.into(), | |
| @@ -467,7 +486,7 @@ pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::Ver | |||
| 467 | 486 | ) | |
| 468 | 487 | .await?; | |
| 469 | 488 | } | |
| 470 | - | db::scanning::update_version_scan_status(&state.db, version_id, db::FileScanStatus::Clean).await?; | |
| 489 | + | db::scanning::update_version_scan_status(db, version_id, db::FileScanStatus::Clean).await?; | |
| 471 | 490 | Ok(()) | |
| 472 | 491 | } | |
| 473 | 492 | ||
| @@ -479,13 +498,15 @@ pub(crate) async fn commit_promote_version(state: &AppState, version_id: db::Ver | |||
| 479 | 498 | /// future sibling handler. This function remains `pub(super)`-equivalent for | |
| 480 | 499 | /// the `commit_upload` implementation and for the worker / admin tooling | |
| 481 | 500 | /// that legitimately needs the lower-level op. | |
| 501 | + | #[allow(clippy::too_many_arguments)] | |
| 482 | 502 | #[tracing::instrument( | |
| 483 | 503 | skip_all, | |
| 484 | 504 | name = "storage::enqueue_scan_for", | |
| 485 | 505 | fields(kind = ?target_kind, %target_id, %user_id, file_size_bytes) | |
| 486 | 506 | )] | |
| 487 | 507 | async fn enqueue_scan_for( | |
| 488 | - | state: &AppState, | |
| 508 | + | db: &PgPool, | |
| 509 | + | scanner: Option<&Arc<ScanPipeline>>, | |
| 489 | 510 | target_kind: ScanTargetKind, | |
| 490 | 511 | target_id: Uuid, | |
| 491 | 512 | s3_key: &str, | |
| @@ -493,8 +514,8 @@ async fn enqueue_scan_for( | |||
| 493 | 514 | user_id: db::UserId, | |
| 494 | 515 | file_size_bytes: i64, | |
| 495 | 516 | ) -> Result<db::FileScanStatus> { | |
| 496 | - | if state.scanner.is_none() { | |
| 497 | - | let is_trusted = db::users::is_upload_trusted(&state.db, user_id).await?; | |
| 517 | + | if scanner.is_none() { | |
| 518 | + | let is_trusted = db::users::is_upload_trusted(db, user_id).await?; | |
| 498 | 519 | let status = if is_trusted { | |
| 499 | 520 | db::FileScanStatus::Clean | |
| 500 | 521 | } else { | |
| @@ -505,7 +526,7 @@ async fn enqueue_scan_for( | |||
| 505 | 526 | } | |
| 506 | 527 | ||
| 507 | 528 | db::scan_jobs::enqueue( | |
| 508 | - | &state.db, | |
| 529 | + | db, | |
| 509 | 530 | target_kind, | |
| 510 | 531 | target_id, | |
| 511 | 532 | s3_key, |
| @@ -6,6 +6,7 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | use std::str::FromStr; | |
| 10 | 11 | ||
| 11 | 12 | use crate::{ | |
| @@ -13,7 +14,7 @@ use crate::{ | |||
| 13 | 14 | db::{self, ItemId}, | |
| 14 | 15 | error::{AppError, Result, ResultExt}, | |
| 15 | 16 | storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 16 | - | AppState, | |
| 17 | + | AppStorage, Scanning, | |
| 17 | 18 | }; | |
| 18 | 19 | ||
| 19 | 20 | use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse}; | |
| @@ -51,13 +52,14 @@ pub struct ConfirmUploadRequest { | |||
| 51 | 52 | /// Requires authentication. User must own the item. | |
| 52 | 53 | #[tracing::instrument(skip_all, name = "storage::presign_upload", fields(user_id = %user.id))] | |
| 53 | 54 | pub(super) async fn presign_upload( | |
| 54 | - | State(state): State<AppState>, | |
| 55 | + | State(db): State<PgPool>, | |
| 56 | + | State(storage): State<AppStorage>, | |
| 55 | 57 | AuthUser(user): AuthUser, | |
| 56 | 58 | Json(req): Json<PresignUploadRequest>, | |
| 57 | 59 | ) -> Result<impl IntoResponse> { | |
| 58 | 60 | user.check_not_suspended()?; | |
| 59 | 61 | // Check if S3 is configured | |
| 60 | - | let s3 = state.require_s3()?; | |
| 62 | + | let s3 = storage.require_s3()?; | |
| 61 | 63 | ||
| 62 | 64 | // Parse file type | |
| 63 | 65 | let file_type = FileType::from_str(&req.file_type) | |
| @@ -70,7 +72,7 @@ pub(super) async fn presign_upload( | |||
| 70 | 72 | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 71 | 73 | ||
| 72 | 74 | // Verify user owns the item | |
| 73 | - | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 75 | + | let owner = db::items::get_item_owner(&db, req.item_id) | |
| 74 | 76 | .await? | |
| 75 | 77 | .ok_or(AppError::NotFound)?; | |
| 76 | 78 | ||
| @@ -79,10 +81,10 @@ pub(super) async fn presign_upload( | |||
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | 83 | // Early quota check (reject before generating presigned URL) | |
| 82 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 84 | + | db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; | |
| 83 | 85 | ||
| 84 | 86 | // 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?; | |
| 87 | + | let max_file_bytes = db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?; | |
| 86 | 88 | ||
| 87 | 89 | // If the client declared the file size, validate it before signing — both | |
| 88 | 90 | // against the static per-type cap and the tier-effective cap. The size is | |
| @@ -98,7 +100,7 @@ pub(super) async fn presign_upload( | |||
| 98 | 100 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 99 | 101 | ||
| 100 | 102 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 101 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 103 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 102 | 104 | ||
| 103 | 105 | // Generate presigned upload URL with immutable cache headers | |
| 104 | 106 | let expires_in = 3600; // 1 hour | |
| @@ -122,20 +124,22 @@ pub(super) async fn presign_upload( | |||
| 122 | 124 | /// Requires authentication. User must own the item. | |
| 123 | 125 | #[tracing::instrument(skip_all, name = "storage::confirm_upload", fields(user_id = %user.id))] | |
| 124 | 126 | pub(super) async fn confirm_upload( | |
| 125 | - | State(state): State<AppState>, | |
| 127 | + | State(db): State<PgPool>, | |
| 128 | + | State(storage): State<AppStorage>, | |
| 129 | + | State(scanning): State<Scanning>, | |
| 126 | 130 | AuthUser(user): AuthUser, | |
| 127 | 131 | Json(req): Json<ConfirmUploadRequest>, | |
| 128 | 132 | ) -> Result<impl IntoResponse> { | |
| 129 | 133 | user.check_not_suspended()?; | |
| 130 | 134 | // Check if S3 is configured | |
| 131 | - | let s3 = state.require_s3()?; | |
| 135 | + | let s3 = storage.require_s3()?; | |
| 132 | 136 | ||
| 133 | 137 | // Parse file type | |
| 134 | 138 | let file_type = FileType::from_str(&req.file_type) | |
| 135 | 139 | .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?; | |
| 136 | 140 | ||
| 137 | 141 | // Verify user owns the item | |
| 138 | - | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 142 | + | let owner = db::items::get_item_owner(&db, req.item_id) | |
| 139 | 143 | .await? | |
| 140 | 144 | .ok_or(AppError::NotFound)?; | |
| 141 | 145 | ||
| @@ -159,7 +163,7 @@ pub(super) async fn confirm_upload( | |||
| 159 | 163 | AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string()) | |
| 160 | 164 | })?; | |
| 161 | 165 | if file_size_bytes as u64 > file_type.max_size() { | |
| 162 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_rejected").await; | |
| 166 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_rejected").await; | |
| 163 | 167 | let limit_mb = file_type.max_size() / (1024 * 1024); | |
| 164 | 168 | let file_mb = file_size_bytes as u64 / (1024 * 1024); | |
| 165 | 169 | return Err(AppError::FileTooLarge(format!( | |
| @@ -169,10 +173,10 @@ pub(super) async fn confirm_upload( | |||
| 169 | 173 | } | |
| 170 | 174 | ||
| 171 | 175 | // Enforce tier-based limits (per-file + storage cap) | |
| 172 | - | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, file_type, file_size_bytes).await { | |
| 176 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, file_type, file_size_bytes).await { | |
| 173 | 177 | Ok(max) => max, | |
| 174 | 178 | Err(e) => { | |
| 175 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_rejected").await; | |
| 179 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_rejected").await; | |
| 176 | 180 | return Err(e); | |
| 177 | 181 | } | |
| 178 | 182 | }; | |
| @@ -186,7 +190,7 @@ pub(super) async fn confirm_upload( | |||
| 186 | 190 | // which only /api/items/image/confirm writes — the old generic two-column | |
| 187 | 191 | // path left it NULL and rendered an invisible cover (Run #13 SERIOUS). | |
| 188 | 192 | if let crate::storage::GenericItemConfirm::UseRoute(route) = file_type.generic_item_confirm() { | |
| 189 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_rejected").await; | |
| 193 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_rejected").await; | |
| 190 | 194 | return Err(AppError::BadRequest(format!( | |
| 191 | 195 | "This file type isn't confirmed here — use {route}." | |
| 192 | 196 | ))); | |
| @@ -197,7 +201,7 @@ pub(super) async fn confirm_upload( | |||
| 197 | 201 | let mut old_s3_key: Option<String> = None; | |
| 198 | 202 | let mut replaced_old_size: i64 = 0; | |
| 199 | 203 | let mut item_project_id: Option<db::ProjectId> = None; | |
| 200 | - | if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? { | |
| 204 | + | if let Some(item) = db::items::get_item_by_id(&db, req.item_id).await? { | |
| 201 | 205 | item_project_id = Some(item.project_id); | |
| 202 | 206 | let existing_key = match file_type { | |
| 203 | 207 | FileType::Audio => item.audio_s3_key.as_deref(), | |
| @@ -210,7 +214,7 @@ pub(super) async fn confirm_upload( | |||
| 210 | 214 | // Still clear the pending_uploads row — otherwise the orphan reaper | |
| 211 | 215 | // will fire 24h later and delete the live S3 object out from under | |
| 212 | 216 | // a perfectly happy DB row (Run #7 HIGH-1). | |
| 213 | - | if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await { | |
| 217 | + | if let Err(e) = db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await { | |
| 214 | 218 | tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); | |
| 215 | 219 | } | |
| 216 | 220 | return Ok(Json(ConfirmUploadResponse { success: true, pending_review: None })); | |
| @@ -239,7 +243,7 @@ pub(super) async fn confirm_upload( | |||
| 239 | 243 | // would treat the not-yet-referenced staging object as dead and delete it — | |
| 240 | 244 | // a cross-user griefing delete. Just reject; the real owner's confirm or the | |
| 241 | 245 | // pending-upload reaper handles their key. | |
| 242 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 246 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 243 | 247 | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 244 | 248 | } | |
| 245 | 249 | ||
| @@ -255,7 +259,7 @@ pub(super) async fn confirm_upload( | |||
| 255 | 259 | // signals the item vanished or lost the CAS race (tx rolled back, nothing | |
| 256 | 260 | // charged). | |
| 257 | 261 | let tx_result: Result<u64> = async { | |
| 258 | - | let mut tx = state.db.begin().await?; | |
| 262 | + | let mut tx = db.begin().await?; | |
| 259 | 263 | db::creator_tiers::try_apply_storage_on( | |
| 260 | 264 | &mut tx, user.id, is_replace.then_some(replaced_old_size), file_size_bytes, max_storage, | |
| 261 | 265 | ).await?; | |
| @@ -306,7 +310,7 @@ pub(super) async fn confirm_upload( | |||
| 306 | 310 | // filling in between), so a blind delete could destroy the live | |
| 307 | 311 | // object the winner points at. Route through the orphan queue; its | |
| 308 | 312 | // `is_s3_key_live` check skips any key a row still references. | |
| 309 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_confirm_failed").await; | |
| 313 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_confirm_failed").await; | |
| 310 | 314 | return Err(e); | |
| 311 | 315 | } | |
| 312 | 316 | Ok(0) => { | |
| @@ -316,7 +320,7 @@ pub(super) async fn confirm_upload( | |||
| 316 | 320 | // observed). Either way the tx rolled back, nothing was charged; | |
| 317 | 321 | // route the now-unreferenced object through the orphan queue so the | |
| 318 | 322 | // reaper still cleans it. | |
| 319 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_target_missing").await; | |
| 323 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "item_upload_target_missing").await; | |
| 320 | 324 | return Err(AppError::BadRequest( | |
| 321 | 325 | "Item was modified concurrently. Please try uploading again.".to_string(), | |
| 322 | 326 | )); | |
| @@ -329,7 +333,8 @@ pub(super) async fn confirm_upload( | |||
| 329 | 333 | // this ordering. See `routes/storage/mod.rs::commit_upload` for the bug | |
| 330 | 334 | // shapes this prevents. | |
| 331 | 335 | let scan_status = commit_upload( | |
| 332 | - | &state, | |
| 336 | + | &db, | |
| 337 | + | scanning.scanner.as_ref(), | |
| 333 | 338 | CommitTarget::Item(req.item_id), | |
| 334 | 339 | &req.s3_key, | |
| 335 | 340 | file_type, | |
| @@ -341,12 +346,12 @@ pub(super) async fn confirm_upload( | |||
| 341 | 346 | // confirm tx above, so a crash here can't orphan it.) | |
| 342 | 347 | ||
| 343 | 348 | // Clear the pending upload record now that the upload is confirmed | |
| 344 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 349 | + | db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; | |
| 345 | 350 | ||
| 346 | 351 | // Bump project cache generation so dashboard tabs reflect the new upload. | |
| 347 | 352 | // Reuses the project_id read during idempotency above (no second fetch). | |
| 348 | 353 | if let Some(project_id) = item_project_id | |
| 349 | - | && let Err(e) = db::projects::bump_cache_generation(&state.db, project_id).await | |
| 354 | + | && let Err(e) = db::projects::bump_cache_generation(&db, project_id).await | |
| 350 | 355 | { | |
| 351 | 356 | tracing::warn!(%project_id, error = ?e, "failed to bump cache generation after upload"); | |
| 352 | 357 | } |
| @@ -6,13 +6,14 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::AuthUser, | |
| 12 | 13 | db::{self, VersionId}, | |
| 13 | 14 | error::{AppError, Result, ResultExt}, | |
| 14 | 15 | storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 15 | - | AppState, | |
| 16 | + | AppStorage, Scanning, | |
| 16 | 17 | }; | |
| 17 | 18 | ||
| 18 | 19 | use super::{commit_upload, CommitTarget, ConfirmUploadResponse, PresignUploadResponse}; | |
| @@ -42,13 +43,14 @@ pub struct VersionConfirmRequest { | |||
| 42 | 43 | /// Requires authentication. User must own the item (through version -> item -> project chain). | |
| 43 | 44 | #[tracing::instrument(skip_all, name = "storage::version_presign_upload", fields(%version_id, user_id = %user.id))] | |
| 44 | 45 | pub(super) async fn version_presign_upload( | |
| 45 | - | State(state): State<AppState>, | |
| 46 | + | State(db): State<PgPool>, | |
| 47 | + | State(storage): State<AppStorage>, | |
| 46 | 48 | AuthUser(user): AuthUser, | |
| 47 | 49 | Path(version_id): Path<VersionId>, | |
| 48 | 50 | Json(req): Json<VersionPresignRequest>, | |
| 49 | 51 | ) -> Result<impl IntoResponse> { | |
| 50 | 52 | user.check_not_suspended()?; | |
| 51 | - | let s3 = state.require_s3()?; | |
| 53 | + | let s3 = storage.require_s3()?; | |
| 52 | 54 | ||
| 53 | 55 | let file_type = FileType::Download; | |
| 54 | 56 | ||
| @@ -57,11 +59,11 @@ pub(super) async fn version_presign_upload( | |||
| 57 | 59 | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 58 | 60 | ||
| 59 | 61 | // Fetch version and verify ownership through version -> item -> project chain | |
| 60 | - | let version = db::versions::get_version_by_id(&state.db, version_id) | |
| 62 | + | let version = db::versions::get_version_by_id(&db, version_id) | |
| 61 | 63 | .await? | |
| 62 | 64 | .ok_or(AppError::NotFound)?; | |
| 63 | 65 | ||
| 64 | - | let owner = db::items::get_item_owner(&state.db, version.item_id) | |
| 66 | + | let owner = db::items::get_item_owner(&db, version.item_id) | |
| 65 | 67 | .await? | |
| 66 | 68 | .ok_or(AppError::NotFound)?; | |
| 67 | 69 | ||
| @@ -70,9 +72,9 @@ pub(super) async fn version_presign_upload( | |||
| 70 | 72 | } | |
| 71 | 73 | ||
| 72 | 74 | // Early quota check | |
| 73 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 75 | + | db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?; | |
| 74 | 76 | ||
| 75 | - | let max_file_bytes = db::creator_tiers::get_effective_max_file_bytes(&state.db, user.id, file_type).await?; | |
| 77 | + | let max_file_bytes = db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?; | |
| 76 | 78 | ||
| 77 | 79 | // Validate the declared size (if any) before signing it into Content-Length. | |
| 78 | 80 | super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?; | |
| @@ -84,7 +86,7 @@ pub(super) async fn version_presign_upload( | |||
| 84 | 86 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 85 | 87 | ||
| 86 | 88 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 87 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 89 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 88 | 90 | ||
| 89 | 91 | let expires_in = 3600; | |
| 90 | 92 | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE), req.file_size_bytes) | |
| @@ -107,20 +109,22 @@ pub(super) async fn version_presign_upload( | |||
| 107 | 109 | /// Requires authentication. User must own the item. | |
| 108 | 110 | #[tracing::instrument(skip_all, name = "storage::version_confirm_upload", fields(%version_id, user_id = %user.id))] | |
| 109 | 111 | pub(super) async fn version_confirm_upload( | |
| 110 | - | State(state): State<AppState>, | |
| 112 | + | State(db): State<PgPool>, | |
| 113 | + | State(storage): State<AppStorage>, | |
| 114 | + | State(scanning): State<Scanning>, | |
| 111 | 115 | AuthUser(user): AuthUser, | |
| 112 | 116 | Path(version_id): Path<VersionId>, | |
| 113 | 117 | Json(req): Json<VersionConfirmRequest>, | |
| 114 | 118 | ) -> Result<impl IntoResponse> { | |
| 115 | 119 | user.check_not_suspended()?; | |
| 116 | - | let s3 = state.require_s3()?; | |
| 120 | + | let s3 = storage.require_s3()?; | |
| 117 | 121 | ||
| 118 | 122 | // Fetch version and verify ownership | |
| 119 | - | let version = db::versions::get_version_by_id(&state.db, version_id) | |
| 123 | + | let version = db::versions::get_version_by_id(&db, version_id) | |
| 120 | 124 | .await? | |
| 121 | 125 | .ok_or(AppError::NotFound)?; | |
| 122 | 126 | ||
| 123 | - | let owner = db::items::get_item_owner(&state.db, version.item_id) | |
| 127 | + | let owner = db::items::get_item_owner(&db, version.item_id) | |
| 124 | 128 | .await? | |
| 125 | 129 | .ok_or(AppError::NotFound)?; | |
| 126 | 130 | ||
| @@ -135,7 +139,7 @@ pub(super) async fn version_confirm_upload( | |||
| 135 | 139 | if version.s3_key.as_deref() == Some(&req.s3_key) { | |
| 136 | 140 | // Still clear pending_uploads — orphan reaper would otherwise delete the | |
| 137 | 141 | // live S3 object 24h later (Run #7 HIGH-1). | |
| 138 | - | if let Err(e) = db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await { | |
| 142 | + | if let Err(e) = db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await { | |
| 139 | 143 | tracing::warn!(error = ?e, key = %req.s3_key, "remove_pending_upload failed on idempotent re-confirm"); | |
| 140 | 144 | } | |
| 141 | 145 | return Ok(Json(ConfirmUploadResponse { success: true, pending_review: None })); | |
| @@ -146,7 +150,7 @@ pub(super) async fn version_confirm_upload( | |||
| 146 | 150 | // `pending_uploads` row recorded at presign — not a prefix check. Placed | |
| 147 | 151 | // before the size/tier reject paths so an unowned (at most another user's | |
| 148 | 152 | // in-flight) staging object is never enqueued for deletion. | |
| 149 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 153 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 150 | 154 | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 151 | 155 | } | |
| 152 | 156 | ||
| @@ -157,7 +161,7 @@ pub(super) async fn version_confirm_upload( | |||
| 157 | 161 | AppError::BadRequest("Upload not found. Please try uploading again.".to_string()) | |
| 158 | 162 | })?; | |
| 159 | 163 | if file_size_bytes as u64 > FileType::Download.max_size() { | |
| 160 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "version_upload_rejected").await; | |
| 164 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "version_upload_rejected").await; | |
| 161 | 165 | let limit_mb = FileType::Download.max_size() / (1024 * 1024); | |
| 162 | 166 | let file_mb = file_size_bytes as u64 / (1024 * 1024); | |
| 163 | 167 | return Err(AppError::FileTooLarge(format!( | |
| @@ -167,10 +171,10 @@ pub(super) async fn version_confirm_upload( | |||
| 167 | 171 | } | |
| 168 | 172 | ||
| 169 | 173 | // Enforce tier-based limits (per-file + storage cap) | |
| 170 | - | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Download, file_size_bytes).await { | |
| 174 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, FileType::Download, file_size_bytes).await { | |
| 171 | 175 | Ok(max) => max, | |
| 172 | 176 | Err(e) => { | |
| 173 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "version_upload_rejected").await; | |
| 177 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "version_upload_rejected").await; | |
| 174 | 178 | return Err(e); | |
| 175 | 179 | } | |
| 176 | 180 | }; | |
| @@ -189,7 +193,7 @@ pub(super) async fn version_confirm_upload( | |||
| 189 | 193 | // `commit_upload` stays AFTER the commit (the blessed scan-ordering path). | |
| 190 | 194 | // `Ok(false)` = lost race (rolled back, nothing charged). | |
| 191 | 195 | let committed: Result<bool> = async { | |
| 192 | - | let mut tx = state.db.begin().await?; | |
| 196 | + | let mut tx = db.begin().await?; | |
| 193 | 197 | db::creator_tiers::try_apply_storage_on( | |
| 194 | 198 | &mut tx, user.id, is_replace.then_some(old_size), file_size_bytes, max_storage, | |
| 195 | 199 | ).await?; | |
| @@ -234,7 +238,7 @@ pub(super) async fn version_confirm_upload( | |||
| 234 | 238 | // would then destroy the live object the winning confirm points at. | |
| 235 | 239 | // Route through the orphan queue, whose `is_s3_key_live` check skips | |
| 236 | 240 | // any key a row still references. | |
| 237 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "version_confirm_failed").await; | |
| 241 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "version_confirm_failed").await; | |
| 238 | 242 | return Err(e); | |
| 239 | 243 | } | |
| 240 | 244 | Ok(false) => { | |
| @@ -242,7 +246,7 @@ pub(super) async fn version_confirm_upload( | |||
| 242 | 246 | // s3_key out from under us. If it committed THIS key, a direct | |
| 243 | 247 | // delete would 404 every fan download of the version the winner | |
| 244 | 248 | // just published. The orphan queue's liveness check is the guard. | |
| 245 | - | super::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "version_confirm_lost_race").await; | |
| 249 | + | super::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "version_confirm_lost_race").await; | |
| 246 | 250 | return Err(AppError::BadRequest( | |
| 247 | 251 | "Version was modified concurrently. Please try uploading again.".to_string(), | |
| 248 | 252 | )); | |
| @@ -251,10 +255,11 @@ pub(super) async fn version_confirm_upload( | |||
| 251 | 255 | } | |
| 252 | 256 | ||
| 253 | 257 | // Clear the pending upload record now that the upload is committed | |
| 254 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 258 | + | db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; | |
| 255 | 259 | ||
| 256 | 260 | let scan_status = commit_upload( | |
| 257 | - | &state, | |
| 261 | + | &db, | |
| 262 | + | scanning.scanner.as_ref(), | |
| 258 | 263 | CommitTarget::Version(version_id), | |
| 259 | 264 | &req.s3_key, | |
| 260 | 265 | FileType::Download, |