max / makenotwork
55 files changed,
+1179 insertions,
-213 deletions
| @@ -3762,6 +3762,25 @@ dependencies = [ | |||
| 3762 | 3762 | ] | |
| 3763 | 3763 | ||
| 3764 | 3764 | [[package]] | |
| 3765 | + | name = "include_dir" | |
| 3766 | + | version = "0.7.4" | |
| 3767 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3768 | + | checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" | |
| 3769 | + | dependencies = [ | |
| 3770 | + | "include_dir_macros", | |
| 3771 | + | ] | |
| 3772 | + | ||
| 3773 | + | [[package]] | |
| 3774 | + | name = "include_dir_macros" | |
| 3775 | + | version = "0.7.4" | |
| 3776 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3777 | + | checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" | |
| 3778 | + | dependencies = [ | |
| 3779 | + | "proc-macro2", | |
| 3780 | + | "quote", | |
| 3781 | + | ] | |
| 3782 | + | ||
| 3783 | + | [[package]] | |
| 3765 | 3784 | name = "indexmap" | |
| 3766 | 3785 | version = "2.14.0" | |
| 3767 | 3786 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4214,6 +4233,7 @@ dependencies = [ | |||
| 4214 | 4233 | "hex", | |
| 4215 | 4234 | "hmac 0.12.1", | |
| 4216 | 4235 | "http-body-util", | |
| 4236 | + | "include_dir", | |
| 4217 | 4237 | "infer", | |
| 4218 | 4238 | "jsonwebtoken", | |
| 4219 | 4239 | "log", | |
| @@ -4238,6 +4258,7 @@ dependencies = [ | |||
| 4238 | 4258 | "syntect", | |
| 4239 | 4259 | "tagtree", | |
| 4240 | 4260 | "tempfile", | |
| 4261 | + | "theme-common", | |
| 4241 | 4262 | "thiserror 2.0.18", | |
| 4242 | 4263 | "tokio", | |
| 4243 | 4264 | "tokio-stream", | |
| @@ -7037,6 +7058,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 7037 | 7058 | checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" | |
| 7038 | 7059 | ||
| 7039 | 7060 | [[package]] | |
| 7061 | + | name = "theme-common" | |
| 7062 | + | version = "0.4.0" | |
| 7063 | + | dependencies = [ | |
| 7064 | + | "serde", | |
| 7065 | + | "toml", | |
| 7066 | + | ] | |
| 7067 | + | ||
| 7068 | + | [[package]] | |
| 7040 | 7069 | name = "thiserror" | |
| 7041 | 7070 | version = "1.0.69" | |
| 7042 | 7071 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -116,6 +116,11 @@ docengine = { path = "../shared/docengine", features = ["doc-loader", "directive | |||
| 116 | 116 | # Tag standard | |
| 117 | 117 | tagtree = { path = "../shared/tagtree" } | |
| 118 | 118 | ||
| 119 | + | # Shared theme palette (Tier 0 creator theming). Themes are embedded at compile | |
| 120 | + | # time from shared/themes/ via include_dir, so production needs no themes path. | |
| 121 | + | theme-common = { path = "../shared/theme-common" } | |
| 122 | + | include_dir = "0.7" | |
| 123 | + | ||
| 119 | 124 | # Git source browser | |
| 120 | 125 | git2 = { version = "0.20", features = ["vendored-libgit2"] } | |
| 121 | 126 | syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] } |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | -- Tier 0 creator theming: a creator picks a built-in theme for their public | |
| 2 | + | -- profile and for each project. NULL means the platform default (mnw-default). | |
| 3 | + | -- The id references a bundled theme-common theme (shared/themes/*.toml); it is | |
| 4 | + | -- validated app-side against the embedded registry before write, so no FK here. | |
| 5 | + | -- The length cap is a defensive backstop against oversized values. | |
| 6 | + | ||
| 7 | + | ALTER TABLE users | |
| 8 | + | ADD COLUMN theme_id TEXT | |
| 9 | + | CHECK (theme_id IS NULL OR char_length(theme_id) <= 64); | |
| 10 | + | ||
| 11 | + | ALTER TABLE projects | |
| 12 | + | ADD COLUMN theme_id TEXT | |
| 13 | + | CHECK (theme_id IS NULL OR char_length(theme_id) <= 64); |
| @@ -365,7 +365,11 @@ pub async fn get_grandfathered_until( | |||
| 365 | 365 | /// Get a per-category storage breakdown for the creator dashboard (single query). | |
| 366 | 366 | #[tracing::instrument(skip_all)] | |
| 367 | 367 | pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<StorageBreakdown> { | |
| 368 | - | let row: (i64, i64, i64, i64, i64, i64) = sqlx::query_as( | |
| 368 | + | // Categories must partition every byte counted by `recalculate_all_storage_batch` | |
| 369 | + | // so the dashboard total reconciles with `storage_used_bytes`. `cover_bytes` | |
| 370 | + | // folds item covers + project covers; `gallery_bytes` covers the item/project | |
| 371 | + | // image carousels (Run #18 Storage B1). | |
| 372 | + | let row: (i64, i64, i64, i64, i64, i64, i64) = sqlx::query_as( | |
| 369 | 373 | r#" | |
| 370 | 374 | WITH audio_bytes AS ( | |
| 371 | 375 | SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total | |
| @@ -373,7 +377,12 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 373 | 377 | WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL | |
| 374 | 378 | ), | |
| 375 | 379 | cover_bytes AS ( | |
| 376 | - | SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total | |
| 380 | + | SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) | |
| 381 | + | + COALESCE(( | |
| 382 | + | SELECT SUM(p.cover_image_size_bytes)::BIGINT | |
| 383 | + | FROM projects p | |
| 384 | + | WHERE p.user_id = $1 AND p.cover_image_size_bytes IS NOT NULL | |
| 385 | + | ), 0) AS total | |
| 377 | 386 | FROM items i JOIN projects p ON i.project_id = p.id | |
| 378 | 387 | WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL | |
| 379 | 388 | ), | |
| @@ -396,6 +405,18 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 396 | 405 | media_bytes AS ( | |
| 397 | 406 | SELECT COALESCE(SUM(file_size_bytes)::BIGINT, 0) AS total | |
| 398 | 407 | FROM media_files WHERE user_id = $1 | |
| 408 | + | ), | |
| 409 | + | gallery_bytes AS ( | |
| 410 | + | SELECT COALESCE(( | |
| 411 | + | SELECT SUM(ii.file_size_bytes)::BIGINT | |
| 412 | + | FROM item_images ii JOIN items i ON ii.item_id = i.id JOIN projects p ON i.project_id = p.id | |
| 413 | + | WHERE p.user_id = $1 | |
| 414 | + | ), 0) | |
| 415 | + | + COALESCE(( | |
| 416 | + | SELECT SUM(pi.file_size_bytes)::BIGINT | |
| 417 | + | FROM project_images pi JOIN projects p ON pi.project_id = p.id | |
| 418 | + | WHERE p.user_id = $1 | |
| 419 | + | ), 0) AS total | |
| 399 | 420 | ) | |
| 400 | 421 | SELECT | |
| 401 | 422 | (SELECT total FROM audio_bytes), | |
| @@ -403,7 +424,8 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 403 | 424 | (SELECT total FROM version_bytes), | |
| 404 | 425 | (SELECT total FROM insertion_bytes), | |
| 405 | 426 | (SELECT total FROM video_bytes), | |
| 406 | - | (SELECT total FROM media_bytes) | |
| 427 | + | (SELECT total FROM media_bytes), | |
| 428 | + | (SELECT total FROM gallery_bytes) | |
| 407 | 429 | "#, | |
| 408 | 430 | ) | |
| 409 | 431 | .bind(user_id) | |
| @@ -417,7 +439,8 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto | |||
| 417 | 439 | insertion_bytes: row.3, | |
| 418 | 440 | video_bytes: row.4, | |
| 419 | 441 | media_bytes: row.5, | |
| 420 | - | total_bytes: row.0 + row.1 + row.2 + row.3 + row.4 + row.5, | |
| 442 | + | gallery_bytes: row.6, | |
| 443 | + | total_bytes: row.0 + row.1 + row.2 + row.3 + row.4 + row.5 + row.6, | |
| 421 | 444 | }) | |
| 422 | 445 | } | |
| 423 | 446 | ||
| @@ -542,7 +565,10 @@ pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> { | |||
| 542 | 565 | + COALESCE(video.total, 0) | |
| 543 | 566 | + COALESCE(versions.total, 0) | |
| 544 | 567 | + COALESCE(insertions.total, 0) | |
| 545 | - | + COALESCE(media.total, 0) AS total | |
| 568 | + | + COALESCE(media.total, 0) | |
| 569 | + | + COALESCE(project_cover.total, 0) | |
| 570 | + | + COALESCE(item_gallery.total, 0) | |
| 571 | + | + COALESCE(project_gallery.total, 0) AS total | |
| 546 | 572 | FROM users u | |
| 547 | 573 | LEFT JOIN LATERAL ( | |
| 548 | 574 | SELECT SUM(i.audio_file_size_bytes)::BIGINT AS total | |
| @@ -572,6 +598,28 @@ pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> { | |||
| 572 | 598 | SELECT SUM(mf.file_size_bytes)::BIGINT AS total | |
| 573 | 599 | FROM media_files mf WHERE mf.user_id = u.id | |
| 574 | 600 | ) media ON true | |
| 601 | + | -- Project cover images charge storage at confirm but live in their own | |
| 602 | + | -- column (projects.cover_image_size_bytes, migration 126), not on items; | |
| 603 | + | -- the gallery carousels (item_images / project_images, migration 135) | |
| 604 | + | -- likewise charge at confirm and decrement on delete. All three were | |
| 605 | + | -- omitted from this recalc, so the weekly drift-correction zeroed every | |
| 606 | + | -- gallery/project-cover charge and the counter oscillated week to week | |
| 607 | + | -- (Run #18 Storage B1). Reconcile them from the same rows the charge writes. | |
| 608 | + | LEFT JOIN LATERAL ( | |
| 609 | + | SELECT SUM(p.cover_image_size_bytes)::BIGINT AS total | |
| 610 | + | FROM projects p | |
| 611 | + | WHERE p.user_id = u.id AND p.cover_image_size_bytes IS NOT NULL | |
| 612 | + | ) project_cover ON true | |
| 613 | + | LEFT JOIN LATERAL ( | |
| 614 | + | SELECT SUM(ii.file_size_bytes)::BIGINT AS total | |
| 615 | + | FROM item_images ii JOIN items i ON ii.item_id = i.id JOIN projects p ON i.project_id = p.id | |
| 616 | + | WHERE p.user_id = u.id | |
| 617 | + | ) item_gallery ON true | |
| 618 | + | LEFT JOIN LATERAL ( | |
| 619 | + | SELECT SUM(pi.file_size_bytes)::BIGINT AS total | |
| 620 | + | FROM project_images pi JOIN projects p ON pi.project_id = p.id | |
| 621 | + | WHERE p.user_id = u.id | |
| 622 | + | ) project_gallery ON true | |
| 575 | 623 | WHERE u.can_create_projects = true | |
| 576 | 624 | ) totals | |
| 577 | 625 | WHERE users.id = totals.user_id AND users.storage_used_bytes IS DISTINCT FROM totals.total | |
| @@ -1053,12 +1101,13 @@ mod tests { | |||
| 1053 | 1101 | insertion_bytes: 400, | |
| 1054 | 1102 | video_bytes: 500, | |
| 1055 | 1103 | media_bytes: 600, | |
| 1056 | - | total_bytes: 100 + 200 + 300 + 400 + 500 + 600, | |
| 1104 | + | gallery_bytes: 700, | |
| 1105 | + | total_bytes: 100 + 200 + 300 + 400 + 500 + 600 + 700, | |
| 1057 | 1106 | }; | |
| 1058 | 1107 | assert_eq!( | |
| 1059 | 1108 | sb.total_bytes, | |
| 1060 | 1109 | sb.audio_bytes + sb.cover_bytes + sb.download_bytes | |
| 1061 | - | + sb.insertion_bytes + sb.video_bytes + sb.media_bytes, | |
| 1110 | + | + sb.insertion_bytes + sb.video_bytes + sb.media_bytes + sb.gallery_bytes, | |
| 1062 | 1111 | ); | |
| 1063 | 1112 | } | |
| 1064 | 1113 |
| @@ -212,3 +212,53 @@ pub async fn reorder_project(pool: &PgPool, project_id: ProjectId, ordered_ids: | |||
| 212 | 212 | tx.commit().await?; | |
| 213 | 213 | Ok(()) | |
| 214 | 214 | } | |
| 215 | + | ||
| 216 | + | // --------------------------------------------------------------------------- | |
| 217 | + | // Lifecycle key collection (delete / purge) | |
| 218 | + | // | |
| 219 | + | // Gallery rows CASCADE away when their parent item/project is deleted, so any | |
| 220 | + | // destructive path must collect their `s3_key`s BEFORE the cascade or the S3 | |
| 221 | + | // objects orphan with no durable record (Run #18 Storage B2). These collectors | |
| 222 | + | // are the gallery half of the per-entity key sweep the item/version collectors | |
| 223 | + | // already do. | |
| 224 | + | // --------------------------------------------------------------------------- | |
| 225 | + | ||
| 226 | + | /// S3 keys of every gallery image (item carousel + project carousel) belonging | |
| 227 | + | /// to a project — both `item_images` (via the project's items) and | |
| 228 | + | /// `project_images`. Call before deleting the project. | |
| 229 | + | #[tracing::instrument(skip_all)] | |
| 230 | + | pub async fn s3_keys_for_project<'e>(executor: impl PgExecutor<'e>, project_id: ProjectId) -> Result<Vec<String>> { | |
| 231 | + | let keys: Vec<String> = sqlx::query_scalar( | |
| 232 | + | r#" | |
| 233 | + | SELECT ii.s3_key | |
| 234 | + | FROM item_images ii JOIN items i ON ii.item_id = i.id | |
| 235 | + | WHERE i.project_id = $1 | |
| 236 | + | UNION ALL | |
| 237 | + | SELECT pi.s3_key | |
| 238 | + | FROM project_images pi | |
| 239 | + | WHERE pi.project_id = $1 | |
| 240 | + | "#, | |
| 241 | + | ) | |
| 242 | + | .bind(project_id) | |
| 243 | + | .fetch_all(executor) | |
| 244 | + | .await?; | |
| 245 | + | Ok(keys) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /// S3 keys of item-gallery images belonging to items soft-deleted more than 7 | |
| 249 | + | /// days ago (the purge horizon). Call before the purge CASCADE destroys the | |
| 250 | + | /// `item_images` rows. (Project galleries are not soft-deleted — projects are | |
| 251 | + | /// hard-deleted via [`s3_keys_for_project`].) | |
| 252 | + | #[tracing::instrument(skip_all)] | |
| 253 | + | pub async fn s3_keys_for_expired_purged_items(pool: &PgPool) -> Result<Vec<String>> { | |
| 254 | + | let keys: Vec<String> = sqlx::query_scalar( | |
| 255 | + | r#" | |
| 256 | + | SELECT ii.s3_key | |
| 257 | + | FROM item_images ii JOIN items i ON ii.item_id = i.id | |
| 258 | + | WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days' | |
| 259 | + | "#, | |
| 260 | + | ) | |
| 261 | + | .fetch_all(pool) | |
| 262 | + | .await?; | |
| 263 | + | Ok(keys) | |
| 264 | + | } |
| @@ -149,18 +149,25 @@ pub async fn update_item_cover_image_url( | |||
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | 151 | /// Atomically update cover image URL, S3 key, and file size in a single UPDATE | |
| 152 | - | /// (defense-in-depth: verifies ownership). | |
| 152 | + | /// (defense-in-depth: verifies ownership), guarded by a compare-and-swap on the | |
| 153 | + | /// existing `cover_s3_key`. | |
| 153 | 154 | /// | |
| 154 | - | /// Returns `true` when the row was actually updated, `false` when the | |
| 155 | - | /// ownership filter matched zero rows (item deleted or moved between | |
| 156 | - | /// projects mid-flight). Callers that fire side-effects after the write — | |
| 157 | - | /// storage credit, scan enqueue, S3 orphan queueing — must check the bool | |
| 158 | - | /// and roll back on false. | |
| 155 | + | /// Returns `true` when the row was actually updated, `false` when the UPDATE | |
| 156 | + | /// matched zero rows — either the ownership filter no-matched (item deleted or | |
| 157 | + | /// moved between projects mid-flight) OR the CAS predicate failed because a | |
| 158 | + | /// concurrent confirm already swapped the cover key out from under the value | |
| 159 | + | /// the caller observed (`expected_old_key`, `NULL` for a first cover). Without | |
| 160 | + | /// the CAS, two concurrent cover confirms each deduct the old size and the loser | |
| 161 | + | /// silently orphans its committed object (Run #18 Storage B4) — the same | |
| 162 | + | /// lost-update shape the audio/video path seals via [`update_item_file_cas`]. | |
| 163 | + | /// Callers that fire side-effects after the write — storage credit, scan | |
| 164 | + | /// enqueue, S3 orphan queueing — must check the bool and roll back on false. | |
| 159 | 165 | #[tracing::instrument(skip_all)] | |
| 160 | 166 | pub async fn update_item_cover<'e>( | |
| 161 | 167 | executor: impl sqlx::PgExecutor<'e>, | |
| 162 | 168 | item_id: ItemId, | |
| 163 | 169 | user_id: UserId, | |
| 170 | + | expected_old_key: Option<&str>, | |
| 164 | 171 | url: &str, | |
| 165 | 172 | s3_key: &str, | |
| 166 | 173 | file_size_bytes: i64, | |
| @@ -169,13 +176,15 @@ pub async fn update_item_cover<'e>( | |||
| 169 | 176 | r#"UPDATE items | |
| 170 | 177 | SET cover_image_url = $2, cover_s3_key = $3, cover_file_size_bytes = $4, updated_at = NOW() | |
| 171 | 178 | WHERE id = $1 | |
| 172 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $5)"#, | |
| 179 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $5) | |
| 180 | + | AND cover_s3_key IS NOT DISTINCT FROM $6"#, | |
| 173 | 181 | ) | |
| 174 | 182 | .bind(item_id) | |
| 175 | 183 | .bind(url) | |
| 176 | 184 | .bind(s3_key) | |
| 177 | 185 | .bind(file_size_bytes) | |
| 178 | 186 | .bind(user_id) | |
| 187 | + | .bind(expected_old_key) | |
| 179 | 188 | .execute(executor) | |
| 180 | 189 | .await?; | |
| 181 | 190 |
| @@ -419,7 +419,8 @@ pub async fn get_expired_deleted_item_storage_by_user(pool: &PgPool) -> Result<V | |||
| 419 | 419 | COALESCE(i.audio_file_size_bytes, 0) + | |
| 420 | 420 | COALESCE(i.cover_file_size_bytes, 0) + | |
| 421 | 421 | COALESCE(i.video_file_size_bytes, 0) + | |
| 422 | - | COALESCE(ver.version_bytes, 0) | |
| 422 | + | COALESCE(ver.version_bytes, 0) + | |
| 423 | + | COALESCE(igal.gallery_bytes, 0) | |
| 423 | 424 | ), 0)::BIGINT AS total_bytes | |
| 424 | 425 | FROM items i | |
| 425 | 426 | JOIN projects p ON i.project_id = p.id | |
| @@ -428,6 +429,10 @@ pub async fn get_expired_deleted_item_storage_by_user(pool: &PgPool) -> Result<V | |||
| 428 | 429 | FROM versions v | |
| 429 | 430 | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 430 | 431 | ) ver ON true | |
| 432 | + | LEFT JOIN LATERAL ( | |
| 433 | + | SELECT COALESCE(SUM(ii.file_size_bytes), 0)::BIGINT AS gallery_bytes | |
| 434 | + | FROM item_images ii WHERE ii.item_id = i.id | |
| 435 | + | ) igal ON true | |
| 431 | 436 | WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days' | |
| 432 | 437 | GROUP BY p.user_id | |
| 433 | 438 | "#, | |
| @@ -476,24 +481,44 @@ pub async fn get_project_version_s3_keys(pool: &PgPool, project_id: super::Proje | |||
| 476 | 481 | Ok(keys) | |
| 477 | 482 | } | |
| 478 | 483 | ||
| 479 | - | /// Sum total file sizes for all items and versions in a project. | |
| 484 | + | /// Sum total file sizes for everything in a project that charges storage: | |
| 485 | + | /// item audio/cover/video, versions, the item and project gallery carousels, | |
| 486 | + | /// and the project cover image. Used to refund storage on project delete — it | |
| 487 | + | /// must cover every category the upload paths charge, or the delete under- | |
| 488 | + | /// refunds and leaves the creator's counter inflated (Run #18 Storage B2). | |
| 480 | 489 | #[tracing::instrument(skip_all)] | |
| 481 | 490 | pub async fn get_project_storage_bytes(pool: &PgPool, project_id: super::ProjectId) -> Result<i64> { | |
| 482 | 491 | let total: i64 = sqlx::query_scalar( | |
| 483 | 492 | r#" | |
| 484 | - | SELECT COALESCE(SUM( | |
| 485 | - | COALESCE(i.audio_file_size_bytes, 0) + | |
| 486 | - | COALESCE(i.cover_file_size_bytes, 0) + | |
| 487 | - | COALESCE(i.video_file_size_bytes, 0) + | |
| 488 | - | COALESCE(ver.version_bytes, 0) | |
| 489 | - | ), 0)::BIGINT | |
| 490 | - | FROM items i | |
| 491 | - | LEFT JOIN LATERAL ( | |
| 492 | - | SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes | |
| 493 | - | FROM versions v | |
| 494 | - | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 495 | - | ) ver ON true | |
| 496 | - | WHERE i.project_id = $1 | |
| 493 | + | SELECT ( | |
| 494 | + | COALESCE(( | |
| 495 | + | SELECT SUM( | |
| 496 | + | COALESCE(i.audio_file_size_bytes, 0) + | |
| 497 | + | COALESCE(i.cover_file_size_bytes, 0) + | |
| 498 | + | COALESCE(i.video_file_size_bytes, 0) + | |
| 499 | + | COALESCE(ver.version_bytes, 0) + | |
| 500 | + | COALESCE(igal.gallery_bytes, 0) | |
| 501 | + | )::BIGINT | |
| 502 | + | FROM items i | |
| 503 | + | LEFT JOIN LATERAL ( | |
| 504 | + | SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes | |
| 505 | + | FROM versions v | |
| 506 | + | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 507 | + | ) ver ON true | |
| 508 | + | LEFT JOIN LATERAL ( | |
| 509 | + | SELECT COALESCE(SUM(ii.file_size_bytes), 0)::BIGINT AS gallery_bytes | |
| 510 | + | FROM item_images ii WHERE ii.item_id = i.id | |
| 511 | + | ) igal ON true | |
| 512 | + | WHERE i.project_id = $1 | |
| 513 | + | ), 0) | |
| 514 | + | + COALESCE(( | |
| 515 | + | SELECT SUM(pi.file_size_bytes)::BIGINT | |
| 516 | + | FROM project_images pi WHERE pi.project_id = $1 | |
| 517 | + | ), 0) | |
| 518 | + | + COALESCE(( | |
| 519 | + | SELECT p.cover_image_size_bytes FROM projects p WHERE p.id = $1 | |
| 520 | + | ), 0) | |
| 521 | + | )::BIGINT | |
| 497 | 522 | "#, | |
| 498 | 523 | ) | |
| 499 | 524 | .bind(project_id) |
| @@ -265,11 +265,15 @@ pub struct ItemFileSizes { | |||
| 265 | 265 | #[derive(Debug, Clone, Default)] | |
| 266 | 266 | pub struct StorageBreakdown { | |
| 267 | 267 | pub audio_bytes: i64, | |
| 268 | + | /// Item cover images plus project cover images (both charge storage). | |
| 268 | 269 | pub cover_bytes: i64, | |
| 269 | 270 | pub download_bytes: i64, | |
| 270 | 271 | pub insertion_bytes: i64, | |
| 271 | 272 | pub video_bytes: i64, | |
| 272 | 273 | pub media_bytes: i64, | |
| 274 | + | /// Gallery carousel images on items and projects (`item_images` / | |
| 275 | + | /// `project_images`). Charged at confirm, decremented on delete. | |
| 276 | + | pub gallery_bytes: i64, | |
| 273 | 277 | pub total_bytes: i64, | |
| 274 | 278 | } | |
| 275 | 279 | ||
| @@ -539,6 +543,7 @@ mod tests { | |||
| 539 | 543 | assert_eq!(sb.insertion_bytes, 0); | |
| 540 | 544 | assert_eq!(sb.video_bytes, 0); | |
| 541 | 545 | assert_eq!(sb.media_bytes, 0); | |
| 546 | + | assert_eq!(sb.gallery_bytes, 0); | |
| 542 | 547 | assert_eq!(sb.total_bytes, 0); | |
| 543 | 548 | } | |
| 544 | 549 | } |
| @@ -48,6 +48,9 @@ pub struct DbProject { | |||
| 48 | 48 | pub ai_tier: super::super::AiTier, | |
| 49 | 49 | /// Required disclosure text when ai_tier is assisted. | |
| 50 | 50 | pub ai_disclosure: Option<String>, | |
| 51 | + | /// Chosen built-in theme id for this project's public pages (and its items, | |
| 52 | + | /// which inherit it). `None` = the platform default. See `crate::theming`. | |
| 53 | + | pub theme_id: Option<String>, | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 53 | 56 | /// A git repository tracked on disk, optionally linked to a project. |
| @@ -194,6 +194,9 @@ pub struct DbUser { | |||
| 194 | 194 | /// the "Regenerate feed URL" dashboard action) revokes the user's existing | |
| 195 | 195 | /// feed link without rotating the global signing secret. Starts at 0. | |
| 196 | 196 | pub feed_key_version: i32, | |
| 197 | + | /// Chosen built-in theme id for this creator's public profile page. `None` = | |
| 198 | + | /// the platform default. References a bundled theme; see `crate::theming`. | |
| 199 | + | pub theme_id: Option<String>, | |
| 197 | 200 | } | |
| 198 | 201 | ||
| 199 | 202 | impl DbUser { | |
| @@ -275,6 +278,7 @@ mod tests { | |||
| 275 | 278 | display_name: None, | |
| 276 | 279 | bio: None, | |
| 277 | 280 | avatar_url: None, | |
| 281 | + | theme_id: None, | |
| 278 | 282 | created_at: Utc::now(), | |
| 279 | 283 | updated_at: Utc::now(), | |
| 280 | 284 | stripe_account_id: account_id.map(|s| s.to_string()), |
| @@ -38,14 +38,21 @@ pub async fn record_pending_upload( | |||
| 38 | 38 | /// can't delete another user's pending row — today's per-handler prefix | |
| 39 | 39 | /// validation makes cross-user collision unreachable, but the function | |
| 40 | 40 | /// signature shouldn't be broader than the invariant it protects. | |
| 41 | + | /// | |
| 42 | + | /// Also scoped to `bucket`: the unique key is `(s3_key, bucket)` (migration | |
| 43 | + | /// 137), so a key present in both the main and synckit buckets has two distinct | |
| 44 | + | /// rows. Matching on `s3_key` alone would let one bucket's confirm clear the | |
| 45 | + | /// other bucket's pending record (Run #18 Storage B7). | |
| 41 | 46 | pub async fn remove_pending_upload<'e>( | |
| 42 | 47 | executor: impl sqlx::PgExecutor<'e>, | |
| 43 | 48 | user_id: UserId, | |
| 44 | 49 | s3_key: &str, | |
| 50 | + | bucket: &str, | |
| 45 | 51 | ) -> Result<()> { | |
| 46 | - | sqlx::query("DELETE FROM pending_uploads WHERE s3_key = $1 AND user_id = $2") | |
| 52 | + | sqlx::query("DELETE FROM pending_uploads WHERE s3_key = $1 AND user_id = $2 AND bucket = $3") | |
| 47 | 53 | .bind(s3_key) | |
| 48 | 54 | .bind(user_id) | |
| 55 | + | .bind(bucket) | |
| 49 | 56 | .execute(executor) | |
| 50 | 57 | .await?; | |
| 51 | 58 | Ok(()) | |
| @@ -66,11 +73,26 @@ pub async fn get_stale_pending_uploads( | |||
| 66 | 73 | Ok(rows) | |
| 67 | 74 | } | |
| 68 | 75 | ||
| 69 | - | /// Bulk-delete pending upload records by S3 key. | |
| 70 | - | pub async fn delete_pending_uploads(pool: &PgPool, s3_keys: &[String]) -> Result<()> { | |
| 71 | - | sqlx::query("DELETE FROM pending_uploads WHERE s3_key = ANY($1)") | |
| 72 | - | .bind(s3_keys) | |
| 73 | - | .execute(pool) | |
| 74 | - | .await?; | |
| 76 | + | /// Bulk-delete pending upload records by `(s3_key, bucket)` pair. Bucket-scoped | |
| 77 | + | /// to match the `(s3_key, bucket)` uniqueness (migration 137): the stale reaper | |
| 78 | + | /// processes per-bucket, so it must clear only the row for the bucket it acted | |
| 79 | + | /// on, not every bucket that happens to share the key (Run #18 Storage B7). | |
| 80 | + | pub async fn delete_pending_uploads(pool: &PgPool, keys: &[(String, String)]) -> Result<()> { | |
| 81 | + | if keys.is_empty() { | |
| 82 | + | return Ok(()); | |
| 83 | + | } | |
| 84 | + | let s3_keys: Vec<&str> = keys.iter().map(|(k, _)| k.as_str()).collect(); | |
| 85 | + | let buckets: Vec<&str> = keys.iter().map(|(_, b)| b.as_str()).collect(); | |
| 86 | + | // Match each (key, bucket) pair positionally via UNNEST so a key in two | |
| 87 | + | // buckets only clears the rows whose bucket was actually reaped. | |
| 88 | + | sqlx::query( | |
| 89 | + | "DELETE FROM pending_uploads pu | |
| 90 | + | USING UNNEST($1::text[], $2::text[]) AS t(s3_key, bucket) | |
| 91 | + | WHERE pu.s3_key = t.s3_key AND pu.bucket = t.bucket", | |
| 92 | + | ) | |
| 93 | + | .bind(&s3_keys) | |
| 94 | + | .bind(&buckets) | |
| 95 | + | .execute(pool) | |
| 96 | + | .await?; | |
| 75 | 97 | Ok(()) | |
| 76 | 98 | } |
| @@ -171,6 +171,25 @@ pub async fn set_project_category( | |||
| 171 | 171 | Ok(()) | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | /// Set or clear a project's creator theme. `None` clears to the platform | |
| 175 | + | /// default. The id is validated against the embedded registry before this call. | |
| 176 | + | #[tracing::instrument(skip_all)] | |
| 177 | + | pub async fn set_project_theme( | |
| 178 | + | pool: &PgPool, | |
| 179 | + | id: ProjectId, | |
| 180 | + | user_id: UserId, | |
| 181 | + | theme_id: Option<&str>, | |
| 182 | + | ) -> Result<()> { | |
| 183 | + | sqlx::query("UPDATE projects SET theme_id = $3, updated_at = NOW() WHERE id = $1 AND user_id = $2") | |
| 184 | + | .bind(id) | |
| 185 | + | .bind(user_id) | |
| 186 | + | .bind(theme_id) | |
| 187 | + | .execute(pool) | |
| 188 | + | .await?; | |
| 189 | + | ||
| 190 | + | Ok(()) | |
| 191 | + | } | |
| 192 | + | ||
| 174 | 193 | /// Permanently delete a project by ID (cascades to items). | |
| 175 | 194 | #[tracing::instrument(skip_all)] | |
| 176 | 195 | pub async fn delete_project(pool: &PgPool, id: ProjectId, user_id: UserId) -> Result<()> { | |
| @@ -300,6 +319,46 @@ pub async fn update_project_image_url<'e>( | |||
| 300 | 319 | Ok(result.rows_affected() > 0) | |
| 301 | 320 | } | |
| 302 | 321 | ||
| 322 | + | /// Confirm an uploaded project cover: set the URL **and** record its byte size, | |
| 323 | + | /// guarded by a compare-and-swap on the existing `cover_image_url`. | |
| 324 | + | /// | |
| 325 | + | /// This is the only path that writes `cover_image_size_bytes` (migration 126), | |
| 326 | + | /// which the storage recalc/breakdown read to reconcile project-cover charges — | |
| 327 | + | /// the plain [`update_project_image_url`] setter (used by the wizard) leaves the | |
| 328 | + | /// size untouched and is not a storage-charging operation. | |
| 329 | + | /// | |
| 330 | + | /// Returns `false` when the UPDATE matched zero rows: either the ownership | |
| 331 | + | /// filter no-matched (project deleted/transferred mid-flight) OR a concurrent | |
| 332 | + | /// confirm already swapped the cover URL out from under `expected_old_url`. The | |
| 333 | + | /// CAS stops two concurrent confirms from each deducting the old size and | |
| 334 | + | /// orphaning the loser's object (Run #18 Storage B4). Callers fire storage | |
| 335 | + | /// credit + S3 cleanup after this and must roll back on `false`. | |
| 336 | + | #[tracing::instrument(skip_all)] | |
| 337 | + | pub async fn update_project_cover_cas<'e>( | |
| 338 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 339 | + | id: ProjectId, | |
| 340 | + | user_id: UserId, | |
| 341 | + | expected_old_url: Option<&str>, | |
| 342 | + | url: &str, | |
| 343 | + | file_size_bytes: i64, | |
| 344 | + | ) -> Result<bool> { | |
| 345 | + | let result = sqlx::query( | |
| 346 | + | r#"UPDATE projects | |
| 347 | + | SET cover_image_url = $1, cover_image_size_bytes = $4, updated_at = NOW() | |
| 348 | + | WHERE id = $2 AND user_id = $3 | |
| 349 | + | AND cover_image_url IS NOT DISTINCT FROM $5"#, | |
| 350 | + | ) | |
| 351 | + | .bind(url) | |
| 352 | + | .bind(id) | |
| 353 | + | .bind(user_id) | |
| 354 | + | .bind(file_size_bytes) | |
| 355 | + | .bind(expected_old_url) | |
| 356 | + | .execute(executor) | |
| 357 | + | .await?; | |
| 358 | + | ||
| 359 | + | Ok(result.rows_affected() > 0) | |
| 360 | + | } | |
| 361 | + | ||
| 303 | 362 | /// Update a project's AI content tier and disclosure. | |
| 304 | 363 | #[tracing::instrument(skip_all)] | |
| 305 | 364 | pub async fn update_project_ai_tier( |
| @@ -101,6 +101,20 @@ pub async fn update_user_profile( | |||
| 101 | 101 | Ok(user) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | /// Set or clear a user's creator theme for their public profile. `None` clears | |
| 105 | + | /// to the platform default. The id is validated against the embedded registry | |
| 106 | + | /// before this call. | |
| 107 | + | #[tracing::instrument(skip_all)] | |
| 108 | + | pub async fn update_user_theme(pool: &PgPool, id: UserId, theme_id: Option<&str>) -> Result<()> { | |
| 109 | + | sqlx::query("UPDATE users SET theme_id = $2, updated_at = NOW() WHERE id = $1") | |
| 110 | + | .bind(id) | |
| 111 | + | .bind(theme_id) | |
| 112 | + | .execute(pool) | |
| 113 | + | .await?; | |
| 114 | + | ||
| 115 | + | Ok(()) | |
| 116 | + | } | |
| 117 | + | ||
| 104 | 118 | /// Replace a user's password hash and invalidate outstanding JWTs. | |
| 105 | 119 | #[tracing::instrument(skip_all)] | |
| 106 | 120 | pub async fn update_user_password(pool: &PgPool, id: UserId, password_hash: &str) -> Result<()> { |
| @@ -266,11 +266,10 @@ pub async fn update_version_file<'e>( | |||
| 266 | 266 | /// handled both side effects (e.g. cascading item delete that batches them). | |
| 267 | 267 | #[tracing::instrument(skip_all)] | |
| 268 | 268 | pub async fn delete_version(pool: &PgPool, version_id: VersionId) -> Result<()> { | |
| 269 | - | let Some(version) = get_version_by_id(pool, version_id).await? else { | |
| 270 | - | return Ok(()); // already gone — idempotent | |
| 271 | - | }; | |
| 272 | - | ||
| 273 | - | // Look up the owning user so we can refund storage. | |
| 269 | + | // Look up the owning user so we can refund storage. Ownership is stable for | |
| 270 | + | // a version's lifetime; its size + key, by contrast, can change under a | |
| 271 | + | // concurrent replace-confirm, so those come from the DELETE's RETURNING | |
| 272 | + | // below — never a pre-tx read (Run #18 Storage B5). | |
| 274 | 273 | let owner_id: Option<super::UserId> = sqlx::query_scalar( | |
| 275 | 274 | r#" | |
| 276 | 275 | SELECT p.user_id | |
| @@ -286,20 +285,22 @@ pub async fn delete_version(pool: &PgPool, version_id: VersionId) -> Result<()> | |||
| 286 | 285 | ||
| 287 | 286 | let mut tx = pool.begin().await?; | |
| 288 | 287 | ||
| 289 | - | // Refund storage ONLY when this DELETE actually removed the row. The | |
| 290 | - | // get_version_by_id / owner lookup above run outside the tx, so a concurrent | |
| 291 | - | // double-delete (double-click / retry) can let both requests past them; | |
| 292 | - | // gating the decrement on rows_affected stops the second from refunding a | |
| 293 | - | // second time and under-counting storage in the creator's favor (Run #12 LOW, | |
| 294 | - | // the delete-side mirror of the confirm handlers' rows-affected discipline). | |
| 295 | - | let deleted = sqlx::query("DELETE FROM versions WHERE id = $1") | |
| 296 | - | .bind(version_id) | |
| 297 | - | .execute(&mut *tx) | |
| 298 | - | .await?; | |
| 288 | + | // DELETE ... RETURNING so the refund + S3 enqueue act on the row's ACTUAL | |
| 289 | + | // state at delete time. A replace-confirm that commits between a pre-tx read | |
| 290 | + | // and this DELETE would otherwise make us refund the OLD size and enqueue | |
| 291 | + | // the OLD key while leaking the new one. RETURNING also gives us the | |
| 292 | + | // rows-affected discipline for free: a concurrent double-delete finds no row | |
| 293 | + | // and refunds nothing (Run #12 LOW + Run #18 Storage B5). | |
| 294 | + | let deleted: Option<(Option<String>, Option<i64>)> = sqlx::query_as( | |
| 295 | + | "DELETE FROM versions WHERE id = $1 RETURNING s3_key, file_size_bytes", | |
| 296 | + | ) | |
| 297 | + | .bind(version_id) | |
| 298 | + | .fetch_optional(&mut *tx) | |
| 299 | + | .await?; | |
| 299 | 300 | ||
| 300 | - | if deleted.rows_affected() > 0 { | |
| 301 | + | if let Some((s3_key, file_size_bytes)) = deleted { | |
| 301 | 302 | if let Some(user_id) = owner_id | |
| 302 | - | && let Some(size) = version.file_size_bytes | |
| 303 | + | && let Some(size) = file_size_bytes | |
| 303 | 304 | && size > 0 | |
| 304 | 305 | { | |
| 305 | 306 | crate::db::creator_tiers::decrement_storage_used(&mut *tx, user_id, size).await?; | |
| @@ -310,10 +311,10 @@ pub async fn delete_version(pool: &PgPool, version_id: VersionId) -> Result<()> | |||
| 310 | 311 | // worker can act), and a crash between commit and a post-commit enqueue | |
| 311 | 312 | // can no longer orphan the object — all three effects are atomic, as the | |
| 312 | 313 | // doc comment promises. | |
| 313 | - | if let Some(s3_key) = version.s3_key.as_deref() { | |
| 314 | + | if let Some(s3_key) = s3_key { | |
| 314 | 315 | crate::db::pending_s3_deletions::enqueue_deletions( | |
| 315 | 316 | &mut *tx, | |
| 316 | - | &[(s3_key.to_string(), "main".to_string())], | |
| 317 | + | &[(s3_key, "main".to_string())], | |
| 317 | 318 | "version_delete", | |
| 318 | 319 | ) | |
| 319 | 320 | .await?; |
| @@ -34,6 +34,7 @@ pub mod scanning; | |||
| 34 | 34 | pub mod storage; | |
| 35 | 35 | pub mod synckit_auth; | |
| 36 | 36 | pub mod templates; | |
| 37 | + | pub mod theming; | |
| 37 | 38 | pub mod tier_prices; | |
| 38 | 39 | pub mod types; | |
| 39 | 40 | pub mod validation; |
| @@ -990,6 +990,7 @@ mod tests { | |||
| 990 | 990 | description: None, | |
| 991 | 991 | project_type: db::ProjectType::General, | |
| 992 | 992 | cover_image_url: None, | |
| 993 | + | theme_id: None, | |
| 993 | 994 | is_public: true, | |
| 994 | 995 | created_at: chrono::Utc::now(), | |
| 995 | 996 | updated_at: chrono::Utc::now(), |
| @@ -137,7 +137,7 @@ pub(super) async fn confirm_insertion( | |||
| 137 | 137 | ||
| 138 | 138 | // Enforce static per-type size limit | |
| 139 | 139 | if file_size_bytes as u64 > FileType::Insertion.max_size() { | |
| 140 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 140 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "insertion_upload_rejected").await; | |
| 141 | 141 | return Err(AppError::BadRequest(format!( | |
| 142 | 142 | "File exceeds maximum size of {} MB", | |
| 143 | 143 | FileType::Insertion.max_size() / (1024 * 1024) | |
| @@ -158,7 +158,7 @@ pub(super) async fn confirm_insertion( | |||
| 158 | 158 | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Insertion, file_size_bytes).await { | |
| 159 | 159 | Ok(max) => max, | |
| 160 | 160 | Err(e) => { | |
| 161 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 161 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "insertion_upload_rejected").await; | |
| 162 | 162 | return Err(e); | |
| 163 | 163 | } | |
| 164 | 164 | }; | |
| @@ -166,12 +166,12 @@ pub(super) async fn confirm_insertion( | |||
| 166 | 166 | // Atomically increment storage BEFORE writing the DB record. | |
| 167 | 167 | // Avoids orphaned unbilled file references. | |
| 168 | 168 | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 169 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 169 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "insertion_upload_rejected").await; | |
| 170 | 170 | return Err(e); | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | // Clear the pending upload record now that the upload is confirmed | |
| 174 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key).await?; | |
| 174 | + | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 175 | 175 | ||
| 176 | 176 | let insertion = db::content_insertions::create_insertion( | |
| 177 | 177 | &state.db, | |
| @@ -295,13 +295,8 @@ pub(super) async fn delete_insertion( | |||
| 295 | 295 | { | |
| 296 | 296 | tracing::warn!(error = ?e, "failed to enqueue S3 deletion for insertion"); | |
| 297 | 297 | } | |
| 298 | - | ||
| 299 | - | // Optionally delete the S3 object (best-effort) | |
| 300 | - | if let Some(ref s3) = state.s3 | |
| 301 | - | && let Some(ref ins) = insertion | |
| 302 | - | { | |
| 303 | - | let _ = s3.delete_object(&ins.storage_key).await; | |
| 304 | - | } | |
| 298 | + | // The durable queue entry above is the sole deletion path; the queue worker | |
| 299 | + | // (the only sanctioned S3 deleter) performs the actual delete. | |
| 305 | 300 | ||
| 306 | 301 | let deleted = db::content_insertions::delete_insertion(&state.db, id, user.id).await?; | |
| 307 | 302 |
| @@ -143,7 +143,7 @@ pub(super) async fn confirm_upload( | |||
| 143 | 143 | AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string()) | |
| 144 | 144 | })?; | |
| 145 | 145 | if file_size_bytes as u64 > file_type.max_size() { | |
| 146 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 146 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "cli_upload_rejected").await; | |
| 147 | 147 | return Err(AppError::BadRequest(format!( | |
| 148 | 148 | "File exceeds maximum size of {} MB", | |
| 149 | 149 | file_type.max_size() / (1024 * 1024) | |
| @@ -161,7 +161,7 @@ pub(super) async fn confirm_upload( | |||
| 161 | 161 | { | |
| 162 | 162 | Ok(max) => max, | |
| 163 | 163 | Err(e) => { | |
| 164 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 164 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "cli_upload_rejected").await; | |
| 165 | 165 | return Err(e); | |
| 166 | 166 | } | |
| 167 | 167 | }; | |
| @@ -169,7 +169,7 @@ pub(super) async fn confirm_upload( | |||
| 169 | 169 | // Reject unsupported file types BEFORE any side effect — same ordering rule | |
| 170 | 170 | // as the web upload handlers (see routes/storage/mod.rs::commit_upload). | |
| 171 | 171 | if !matches!(file_type, FileType::Audio | FileType::Download | FileType::Video) { | |
| 172 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 172 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "cli_upload_rejected").await; | |
| 173 | 173 | return Err(AppError::BadRequest( | |
| 174 | 174 | "CLI upload only supports audio, video, and download file types".to_string(), | |
| 175 | 175 | )); | |
| @@ -178,11 +178,11 @@ pub(super) async fn confirm_upload( | |||
| 178 | 178 | // Increment storage BEFORE writing the DB record (if quota exceeded, the | |
| 179 | 179 | // S3 object is cleaned up and no DB record is created). | |
| 180 | 180 | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, req.user_id, file_size_bytes, max_storage).await { | |
| 181 | - | s3.delete_object(&req.s3_key).await.ok(); | |
| 181 | + | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, "cli_upload_rejected").await; | |
| 182 | 182 | return Err(e); | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | db::pending_uploads::remove_pending_upload(&state.db, req.user_id, &req.s3_key).await?; | |
| 185 | + | db::pending_uploads::remove_pending_upload(&state.db, req.user_id, &req.s3_key, "main").await?; | |
| 186 | 186 | ||
| 187 | 187 | // Update the database with S3 key and file size. | |
| 188 | 188 | let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string()); |
| @@ -187,6 +187,7 @@ pub fn api_routes() -> CsrfRouter<AppState> { | |||
| 187 | 187 | .route("/api/users/me/preferences", put_csrf(users::update_preferences)) | |
| 188 | 188 | .route("/api/users/me/stripe", delete_csrf(users::disconnect_stripe)) | |
| 189 | 189 | .route("/api/users/me/stripe-tax", put_csrf(users::toggle_stripe_tax)) | |
| 190 | + | .route("/api/users/me/theme", put_csrf(users::update_profile_theme)) | |
| 190 | 191 | .route("/api/users/me", delete_csrf(users::delete_account)) | |
| 191 | 192 | .route("/api/users/me/deactivate", post_csrf(users::deactivate_account)) | |
| 192 | 193 | .route("/api/users/me/reactivate", post_csrf(users::reactivate_account)) | |
| @@ -197,6 +198,7 @@ pub fn api_routes() -> CsrfRouter<AppState> { | |||
| 197 | 198 | // Project routes | |
| 198 | 199 | .route("/api/projects", post_csrf(projects::create_project)) | |
| 199 | 200 | .route("/api/projects/{id}", put_csrf(projects::update_project)) | |
| 201 | + | .route("/api/projects/{id}/theme", put_csrf(projects::update_project_theme)) | |
| 200 | 202 | .route("/api/projects/{id}", delete_csrf(projects::delete_project)) | |
| 201 | 203 | // Git repo management | |
| 202 | 204 | .route("/api/repos", post_csrf(projects::create_repo)) |
| @@ -312,6 +312,35 @@ pub(super) async fn update_project( | |||
| 312 | 312 | })) | |
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | + | /// Form input for choosing a project's creator theme (Tier 0). | |
| 316 | + | #[derive(Debug, Deserialize)] | |
| 317 | + | pub struct UpdateProjectThemeRequest { | |
| 318 | + | /// Built-in theme id. Empty or absent clears to the platform default. | |
| 319 | + | pub theme_id: Option<String>, | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | /// Set a project's creator theme. Applies to the project's public page and its | |
| 323 | + | /// items (which inherit it). Bumps the project cache generation so cached pages | |
| 324 | + | /// re-render with the new palette. | |
| 325 | + | #[tracing::instrument(skip_all, name = "projects::update_project_theme", fields(project_id))] | |
| 326 | + | pub(super) async fn update_project_theme( | |
| 327 | + | State(state): State<AppState>, | |
| 328 | + | AuthUser(user): AuthUser, | |
| 329 | + | Path(id): Path<ProjectId>, | |
| 330 | + | Form(req): Form<UpdateProjectThemeRequest>, | |
| 331 | + | ) -> Result<impl IntoResponse> { | |
| 332 | + | tracing::Span::current().record("project_id", tracing::field::display(&id)); | |
| 333 | + | user.check_not_suspended()?; | |
| 334 | + | verify_project_ownership(&state, id, user.id).await?; | |
| 335 | + | ||
| 336 | + | let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref()) | |
| 337 | + | .map_err(|t| AppError::validation(format!("Unknown theme: {t}")))?; | |
| 338 | + | db::projects::set_project_theme(&state.db, id, user.id, theme_id.as_deref()).await?; | |
| 339 | + | db::projects::bump_cache_generation(&state.db, id).await?; | |
| 340 | + | ||
| 341 | + | Ok(htmx_toast_response("Theme saved", "success")) | |
| 342 | + | } | |
| 343 | + | ||
| 315 | 344 | /// Delete a project owned by the authenticated user. | |
| 316 | 345 | /// | |
| 317 | 346 | /// Before deleting, enqueues all S3 keys (item files, version files, project | |
| @@ -326,14 +355,19 @@ pub(super) async fn delete_project( | |||
| 326 | 355 | user.check_not_suspended()?; | |
| 327 | 356 | let project = verify_project_ownership(&state, id, user.id).await?; | |
| 328 | 357 | ||
| 329 | - | // Collect all S3 keys from items + versions before CASCADE delete destroys them | |
| 358 | + | // Collect all S3 keys from items + versions + galleries before CASCADE | |
| 359 | + | // delete destroys them. Gallery rows (item_images / project_images) cascade | |
| 360 | + | // away too, so their keys must be swept here or they orphan with no durable | |
| 361 | + | // record (Run #18 Storage B2). | |
| 330 | 362 | let item_keys = db::items::get_project_item_s3_keys(&state.db, id).await?; | |
| 331 | 363 | let version_keys = db::items::get_project_version_s3_keys(&state.db, id).await?; | |
| 364 | + | let gallery_keys = db::gallery_images::s3_keys_for_project(&state.db, id).await?; | |
| 332 | 365 | ||
| 333 | 366 | // Include the project cover image if present | |
| 334 | 367 | let mut all_keys: Vec<(String, String)> = item_keys | |
| 335 | 368 | .into_iter() | |
| 336 | 369 | .chain(version_keys) | |
| 370 | + | .chain(gallery_keys) | |
| 337 | 371 | .map(|k| (k, "main".to_string())) | |
| 338 | 372 | .collect(); | |
| 339 | 373 |