Skip to main content

max / makenotwork

server: Tier 0 creator theming + fold in Run 18 storage work Unified theming groundwork (shared with AF/GO via theme-common): - theme-common 0.4.0: to_css_vars/to_css_declarations/PRIMITIVE_VARS — the single TOML->CSS mapping for the primitive layer — plus parse_theme_str. - shared/themes/mnw-default.toml: today's parchment look as a portable 14-token palette. - style.css: split :root into a primitive layer (theme-common var names) and a semantic layer derived from it; existing brand tokens kept as derived aliases (lossless, no call-site churn). - Tier 0: creators pick a built-in theme per profile/project (items inherit the parent project). Migration 138 adds nullable theme_id to users+projects; src/theming.rs embeds the bundled themes at compile time and caches their rendered CSS; theme_css is injected into profile/project/item <head>; pickers in the profile and project settings tabs; PUT /api/users/me/theme and PUT /api/projects/{id}/theme (validated, project bumps cache_generation). Also folds in the outstanding Run 18 storage/upload lifecycle changes present in the working tree (S3DeleteAuthority threading, orphan-queue confirms). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 00:07 UTC
Commit: e2a838126548522360b6ce39460f4ecea99edcbe
Parent: 8b05698
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