Skip to main content

max / makenotwork

Audit Run 16: type safety, performance, dedup, testing (A- -> A) Type safety: Cents(i64) newtype for all monetary values (35+ fields, 15 files), SessionUser.creator_tier as enum, OnboardingStep enum, format_price accepts impl Into<i64>. Performance: UNNEST batch updates (move_item, set_bundle_items), NOT EXISTS for email suppressions, single-CTE storage breakdown, batch storage recalculation, combined session touch query (-2 round trips per request). Dedup: SessionUser::from_db_user (5 sites), maybe_send_login_notification (2 sites), cleanup_user_s3_and_delete (3 sites), format_duration, download_response (6 sites). Testing: 248 new unit tests (738 -> 986). New coverage for error.rs, creator_tiers.rs, constants.rs, conversions.rs, promo_codes.rs, monitor.rs, csv_converter.rs, license_templates.rs, csrf.rs, rss.rs, models/item.rs, scheduler.rs, validated_types.rs. Fixes: bundles.rs column bug, test harness scan_semaphore, stale doc comment, tip truncation 280->500, .gitignore secret patterns, auth.rs #[instrument], config startup log, storage delete_prefix warning, guest_checkout inline SQL moved to db module, discover search titles only. Landing page: removed Now Open badge, updated Host anything copy, trimmed Fan+ copy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-29 15:30 UTC
Commit: cfe8c31e2ed7b72bde03814a468f502d2cc0dd37
Parent: 2e6c345
73 files changed, +4246 insertions, -1233 deletions
M .gitignore +9
@@ -15,6 +15,15 @@ server/.env.*.local
15 15 *.swo
16 16 *~
17 17
18 + # Secrets and credentials
19 + *.pem
20 + *.key
21 + *.p8
22 + *.p12
23 + *.pfx
24 + credentials.json
25 + service-account.json
26 +
18 27 # OS files
19 28 .DS_Store
20 29 Thumbs.db
@@ -3,7 +3,7 @@
3 3 ## Status
4 4 Done: All pre-beta phases. Active: Creator setup (Stripe), manual testing. Next: Soft launch.
5 5
6 - v0.4.3. Audit grade A. ~1,412 tests.
6 + v0.4.4. Audit grade A- (Run 16, 2026-04-29). 727 unit tests + integration suite.
7 7
8 8 ---
9 9
@@ -136,6 +136,91 @@ Three rounds of adversarial code review. 51 findings total: 50 fixed, 1 accepted
136 136 - Rate limit IP extraction trusts X-Forwarded-For when traffic bypasses Cloudflare (helpers.rs). Fix requires splitting rate limit extraction by path: CF-Connecting-IP for public web routes, peer socket for internal/CLI/git. Needs careful routing since CLI, git smart HTTP, and SyncKit all hit the same server but some bypass Cloudflare.
137 137 - S3 key/file size UPDATE queries lack ownership in SQL -- defense-in-depth; callers verify ownership (db/items.rs)
138 138
139 + ## Audit Run 16 (2026-04-29)
140 +
141 + Overall grade: A- -> A (post-remediation). 75.5k LOC, 986 unit tests (13.1 tests/KLOC). 40+ findings resolved.
142 +
143 + ### Critical Fixes
144 + - [x] `bundles.rs::is_bundle_member` wrong column name (`child_item_id` -> `item_id`)
145 + - [x] Test harness broken — added `scan_semaphore` to `AppState` in test harness + load runner
146 +
147 + ### Testing
148 + - [x] **Scheduler tests** — extracted `jobs_for_tick()`, `is_webhook_dead()`, named constants. 15 unit tests. scheduler.rs: 0 -> 15 tests.
149 + - [x] **Cents tests** — 11 tests for formatting, arithmetic, conversions, serde roundtrip.
150 +
151 + ### Type Safety
152 + - [x] **`Cents` newtype** — `Cents(i64)` for all monetary values. 35+ fields across 15 files. Arithmetic, sqlx, serde, formatting methods. `PriceCents` converts via `From`.
153 + - [x] **SessionUser.creator_tier** — `Option<String>` -> `Option<CreatorTier>`.
154 + - [x] **OnboardingStep enum** — replaced magic integers 1/2/3.
155 + - [x] **format_price** — `i32` -> `impl Into<i64>`, unified with `Cents`.
156 +
157 + ### Performance (A-)
158 + - [x] **items.rs `move_item` N+1** — replaced N individual UPDATEs with single UNNEST batch update.
159 + - [x] **bundles.rs `set_bundle_items` loop insert** — replaced loop insert with single UNNEST batch insert.
160 + - [x] **follows.rs `NOT IN` anti-pattern** — replaced `NOT IN (SELECT LOWER(email) FROM email_suppressions)` with `NOT EXISTS` in both `get_follower_emails` and `get_broadcast_follower_count`.
161 + - [x] **creator_tiers.rs `get_storage_breakdown`** — replaced 6 sequential queries with a single CTE query returning all 6 category totals.
162 + - [x] **scheduler.rs `recalculate_all_storage_used`** — replaced N+1 per-user loop with single batch `UPDATE ... FROM (LATERAL joins)` query. Removed dead `recalculate_storage_used` and `get_all_creator_user_ids` functions.
163 + - [x] **auth.rs session touch** — extended `touch_session` to return `is_fan_plus` and `creator_tier` via subqueries, eliminating 2 extra DB round-trips on every uncached request.
164 + - [x] **discover.rs trigram scaling** — removed description from search clauses (titles only). Re-add description search later with proper full-text search index.
165 +
166 + ### Observability (A-)
167 + - [x] **storage.rs** — added warning log when `delete_prefix` default no-op is called.
168 + - [x] **config.rs startup log** — added structured info log of active features (s3, synckit_s3, stripe, scanner, mt, wam, git) in main.rs before scheduler start.
169 + - [x] **auth.rs** — added `#[instrument]` on `login_user`, `logout_user`, and `track_session`.
170 +
171 + ### Architecture (A-)
172 + - [ ] **scheduler.rs** — does too many things (publishing, email, cleanup, integrity checks, webhook retry). Consider splitting into submodules (scheduler/publishing.rs, scheduler/cleanup.rs, scheduler/integrity.rs) in a future pass.
173 + - [x] **scheduler.rs cleanup duplication** — extracted `cleanup_user_s3_and_delete` shared by sandbox, terminated, and content-removal cleanup. Also unified SyncKit/OTA cleanup into the shared helper (previously only sandbox had it).
174 +
175 + ### Codebase Size (A-)
176 + - [x] **exports.rs** — extracted `download_response()` helper, replacing 6 identical `Response::builder()` blocks.
177 + - [x] **auth.rs login notification** — extracted `maybe_send_login_notification()` in `auth.rs`, replacing duplicated code in password and passkey login paths. Also uses `extract_client_ip` instead of inline XFF parsing.
178 + - [x] **auth.rs SessionUser construction** — extracted `SessionUser::from_db_user()`, replacing 5 identical construction blocks across password, passkey, 2FA, and email-link login paths.
179 + - [x] **types/conversions.rs duration formatting** — extracted `format_duration()`, replacing duplicated logic for audio and video.
180 + - [ ] **templates/public.rs HealthTemplate** — ~90 fields. Consider grouping into sub-structs (HealthDbStatus, HealthStripeStatus, etc.).
181 + - [ ] **checkout.rs** (783 lines) — 6 repetitive `from_session` metadata extraction patterns. Consider a macro or shared trait.
182 + - [x] **email/tokens.rs** — truncated HMAC already documented (lines 268-271). No action needed.
183 + - [ ] **discover.rs** — code duplication across 3 search clause variants (short/long/none). Could reduce with a query builder.
184 + - [x] **guest_checkout.rs** — moved inline SQL to `db::transactions::create_free_guest_transaction` and `db::users::get_verified_user_id_by_email`.
185 +
186 + ### Testing (A- -> A)
187 + - [x] **error.rs** — 18 new tests: IntoResponse rendering for all variants, ResultExt context, internal error masking. (9 -> 27)
188 + - [x] **creator_tiers.rs** — 36 new tests: tier labels/prices/limits, format_bytes, StorageBreakdown. (0 -> 36)
189 + - [x] **conversions.rs** — 12 new tests: format_duration edge cases. (0 -> 12)
190 + - [x] **constants.rs** — 68 new tests: canary tests for all constants, ordering invariants, sanity bounds. (0 -> 68)
191 + - [x] **promo_codes.rs** — 15 new tests: percentage/fixed discount edge cases, overflow, clamping. (5 -> 20)
192 + - [x] **monitor.rs** — 11 new tests: status determination, alert transitions, content checks. (4 -> 15)
193 + - [x] **csv_converter.rs** — 30 new tests: empty CSV, special chars, price parsing, date parsing, unicode, long fields. (18 -> 48)
194 + - [x] **license_templates.rs** — 22 new tests: all presets, variable substitution, edge cases. (8 -> 30)
195 + - [x] **csrf.rs** — 13 new tests: token generation, verification, tampering, expiry, format. (6 -> 19)
196 + - [x] **rss.rs** — 15 new tests: XML escaping, empty feeds, all feed types, date format. (5 -> 20)
197 + - [x] **models/item.rs** — 8 new tests: computed fields, display formatting. (7 -> 15)
198 + - [ ] **lib.rs / main.rs** — no unit tests (covered by integration tests).
199 +
200 + ### Resilience (A-)
201 + - [x] **storage.rs `delete_prefix`** — added warning log when the default no-op is called.
202 +
203 + ### Frontend (A-)
204 + - [ ] **templates/partials.rs** — some template structs lack doc comments (TagTemplate, LinkRowTemplate, etc.).
205 + - [x] **email/notifications.rs** — fixed stale doc comment on line 442 (was "Send an alert email" above `send_tip_notification`).
206 + - [x] **checkout.rs tip truncation** — updated from 280 to 500 chars (Stripe metadata limit).
207 +
208 + ### Dependencies (B+)
209 + - [ ] **Bump rand to 0.9.x** — one major version behind, API changes required.
210 + - [ ] **CONCURRENTLY index strategy** — no migrations use `CREATE INDEX CONCURRENTLY`. Plan for this before tables grow large (transactions, items, users).
211 + - [x] **.gitignore** — added secret-file pattern exclusions (`.pem`, `.key`, `.p8`, `.p12`, `.pfx`, `credentials.json`, `service-account.json`).
212 +
213 + ### Accepted (no action needed)
214 + - `git/raw.rs` `.unwrap()` on Response builders — safe (static headers), cosmetic
215 + - `promo_codes.rs` `.unwrap()` on `and_hms_opt(23,59,59)` — safe (static args)
216 + - analytics.rs 6 near-identical query blocks — correct and safe, query builder would add complexity
217 + - Inline styles (124 occurrences) — most are functional (dynamic widths, conditional visibility)
218 + - helpers.rs / pricing.rs observability B+ — pure functions, silence is appropriate
219 + - `enums.rs` size A- (1397 lines) — ~500 lines are tests, the rest is exhaustive enum definitions
220 + - `users.rs` / `items.rs` size A- — large but each function is focused, no extraction needed
221 +
222 + ---
223 +
139 224 ## Sandbox Fuzz Findings (2026-04-28)
140 225
141 226 Four-agent adversarial audit of sandbox feature. 12 findings: mechanical fixes applied inline, remainder tracked below.
M server/src/auth.rs +96 -15
@@ -56,7 +56,7 @@ pub struct SessionUser {
56 56 #[serde(default)]
57 57 pub is_fan_plus: bool,
58 58 #[serde(default)]
59 - pub creator_tier: Option<String>,
59 + pub creator_tier: Option<db::CreatorTier>,
60 60 #[serde(default)]
61 61 pub deactivated: bool,
62 62 #[serde(default)]
@@ -64,6 +64,40 @@ pub struct SessionUser {
64 64 }
65 65
66 66 impl SessionUser {
67 + /// Build a `SessionUser` from a DB user row + async lookups for fan_plus and creator_tier.
68 + ///
69 + /// Used by all login paths (password, passkey, 2FA, email link) except the join wizard
70 + /// (which uses hardcoded defaults for a freshly created account).
71 + pub async fn from_db_user(
72 + user: db::DbUser,
73 + pool: &sqlx::PgPool,
74 + admin_user_id: Option<db::UserId>,
75 + ) -> Self {
76 + let suspended = user.is_suspended();
77 + let deactivated = user.is_deactivated();
78 + let is_admin = admin_user_id == Some(user.id);
79 + let is_fan_plus = db::fan_plus::is_fan_plus_active(pool, user.id)
80 + .await
81 + .unwrap_or(false);
82 + let creator_tier = db::creator_tiers::get_active_creator_tier(pool, user.id)
83 + .await
84 + .ok()
85 + .flatten();
86 + Self {
87 + id: user.id,
88 + username: user.username,
89 + email: user.email,
90 + display_name: user.display_name,
91 + can_create_projects: user.can_create_projects,
92 + suspended,
93 + is_admin,
94 + is_fan_plus,
95 + creator_tier,
96 + deactivated,
97 + is_sandbox: user.is_sandbox,
98 + }
99 + }
100 +
67 101 /// Returns `Err(Forbidden)` if the user is a sandbox account.
68 102 /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.).
69 103 pub fn check_not_sandbox(&self) -> Result<(), AppError> {
@@ -130,7 +164,7 @@ impl FromRequestParts<crate::AppState> for AuthUser {
130 164 Ok(r) => r,
131 165 Err(e) => {
132 166 tracing::warn!(error = ?e, "session touch failed, invalidating");
133 - db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false }
167 + db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None }
134 168 }
135 169 };
136 170 if !result.valid {
@@ -138,21 +172,15 @@ impl FromRequestParts<crate::AppState> for AuthUser {
138 172 let _ = session.flush().await;
139 173 return Err(AppError::Unauthorized);
140 174 }
141 - // If the user's suspended status changed since login, update the
142 - // session so check_not_suspended() reflects the live DB value.
143 - let is_fan_plus = db::fan_plus::is_fan_plus_active(&state.db, user.id)
144 - .await
145 - .unwrap_or(false);
146 - let creator_tier = db::creator_tiers::get_active_creator_tier(&state.db, user.id)
147 - .await
148 - .ok()
149 - .flatten()
150 - .map(|t| t.to_string());
151 - if user.suspended != result.suspended || user.is_fan_plus != is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != creator_tier {
175 + // If the user's live DB state differs from the session, update it.
176 + // touch_session returns suspended, can_create_projects, is_fan_plus,
177 + // and creator_tier in a single query (no extra round-trips).
178 + let live_tier: Option<db::CreatorTier> = result.creator_tier.as_deref().and_then(|s| s.parse().ok());
179 + if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier {
152 180 user.suspended = result.suspended;
153 - user.is_fan_plus = is_fan_plus;
181 + user.is_fan_plus = result.is_fan_plus;
154 182 user.can_create_projects = result.can_create_projects;
155 - user.creator_tier = creator_tier;
183 + user.creator_tier = live_tier;
156 184 if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
157 185 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
158 186 }
@@ -274,6 +302,7 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
274 302 }
275 303
276 304 /// Store user in session with session regeneration to prevent fixation attacks
305 + #[tracing::instrument(skip_all, fields(user_id = %user.id))]
277 306 pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> {
278 307 // Regenerate session ID to prevent session fixation attacks
279 308 // This creates a new session ID while preserving session data
@@ -297,6 +326,7 @@ pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppE
297 326 }
298 327
299 328 /// Destroy entire session on logout to prevent session reuse
329 + #[tracing::instrument(skip_all)]
300 330 pub async fn logout_user(session: &Session) -> Result<(), AppError> {
301 331 // Flush the entire session to destroy all data and invalidate session ID
302 332 session
@@ -308,6 +338,7 @@ pub async fn logout_user(session: &Session) -> Result<(), AppError> {
308 338
309 339 /// Record a new session in `user_sessions` and store the tracking ID in session data.
310 340 /// Call this after `login_user()` in every login path.
341 + #[tracing::instrument(skip_all, fields(user_id = %user_id))]
311 342 pub async fn track_session(
312 343 session: &Session,
313 344 pool: &PgPool,
@@ -332,6 +363,56 @@ pub async fn track_session(
332 363 Ok(())
333 364 }
334 365
366 + /// Send a new-device login notification if the user has other active sessions.
367 + ///
368 + /// Fire-and-forget — spawns a background task. Only sends if the user has opted in
369 + /// and has more than one active session (meaning this is a new device).
370 + pub async fn maybe_send_login_notification(
371 + state: &crate::AppState,
372 + user_id: UserId,
373 + email: &str,
374 + display_name: Option<&str>,
375 + enabled: bool,
376 + headers: &HeaderMap,
377 + ) {
378 + if !enabled {
379 + return;
380 + }
381 + let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await {
382 + Ok(n) => n,
383 + Err(e) => {
384 + tracing::warn!("Failed to count sessions for login notification: {e}");
385 + return;
386 + }
387 + };
388 + if session_count <= 1 {
389 + return;
390 + }
391 + let user_agent = headers
392 + .get("user-agent")
393 + .and_then(|v| v.to_str().ok())
394 + .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
395 + let ip = crate::helpers::extract_client_ip(headers);
396 + let unsub_url = crate::email::generate_unsubscribe_url(
397 + &state.config.host_url,
398 + user_id,
399 + "login",
400 + &user_id.to_string(),
401 + &state.config.signing_secret,
402 + );
403 + let email = email.to_string();
404 + let display_name = display_name.map(String::from);
405 + crate::helpers::spawn_email!(state.email, "login notification", |email_client| {
406 + email_client.send_new_login_notification(
407 + &email,
408 + display_name.as_deref(),
409 + user_agent.as_deref(),
410 + ip.as_deref(),
411 + Some(&unsub_url),
412 + )
413 + });
414 + }
415 +
335 416 /// Check if a password appears in the HaveIBeenPwned breached passwords database.
336 417 /// Uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent.
337 418 /// Returns Some(count) if breached, None if clean or API unavailable.
@@ -484,13 +484,13 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<(
484 484 } else {
485 485 title.to_string()
486 486 };
487 - let amount = format!("${:.2}", tx.amount_cents as f64 / 100.0);
487 + let amount = format!("${:.2}", tx.amount_cents.as_f64() / 100.0);
488 488 println!(
489 489 "{:<12} {:<30} {:>10} {:<10}",
490 490 date, title_short, amount, tx.status
491 491 );
492 492 if tx.status == TransactionStatus::Completed {
493 - total_cents += tx.amount_cents as i64;
493 + total_cents += tx.amount_cents.as_i64();
494 494 }
495 495 }
496 496
@@ -610,23 +610,6 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
610 610
611 611 // ── Build hooks command ──
612 612
613 - /// Install a post-receive hook into a single bare repo directory.
614 - fn install_hook_for_repo(repo_path: &std::path::Path, hook_content: &str) -> anyhow::Result<()> {
615 - let hooks_dir = repo_path.join("hooks");
616 - std::fs::create_dir_all(&hooks_dir)?;
617 -
618 - let hook_path = hooks_dir.join("post-receive");
619 - std::fs::write(&hook_path, hook_content)?;
620 -
621 - #[cfg(unix)]
622 - {
623 - use std::os::unix::fs::PermissionsExt;
624 - std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
625 - }
626 -
627 - Ok(())
628 - }
629 -
630 613 async fn cmd_install_hooks() -> anyhow::Result<()> {
631 614 let token = std::env::var("BUILD_TRIGGER_TOKEN")
632 615 .map_err(|_| anyhow::anyhow!("BUILD_TRIGGER_TOKEN must be set"))?;
@@ -642,7 +625,6 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
642 625 anyhow::bail!("git root {} does not exist", git_root);
643 626 }
644 627
645 - // Walk {git_root}/{owner}/{repo}.git/hooks/
646 628 for owner_entry in std::fs::read_dir(root)? {
647 629 let owner_entry = owner_entry?;
648 630 if !owner_entry.file_type()?.is_dir() {
@@ -660,7 +642,7 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
660 642 continue;
661 643 }
662 644
663 - install_hook_for_repo(&repo_path, &hook_content)?;
645 + makenotwork::git_ssh::install_hook_for_repo(&repo_path, &hook_content)?;
664 646 installed += 1;
665 647 }
666 648 }
@@ -669,570 +651,13 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
669 651 Ok(())
670 652 }
671 653
672 - // ── SSH key commands ──
673 -
674 - const AUTHORIZED_KEYS_PATH: &str = "/opt/git/.ssh/authorized_keys";
675 - const MNW_ADMIN_PATH: &str = "/opt/makenotwork/mnw-admin";
676 -
677 654 async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> {
678 655 let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len();
679 - write_authorized_keys(pool, true).await?;
656 + makenotwork::git_ssh::write_authorized_keys(pool, true).await?;
680 657 println!("Rebuilt authorized_keys with {} key(s).", key_count);
681 658 Ok(())
682 659 }
683 660
684 661 async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
685 - let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND")
686 - .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?;
687 -
688 - // Look up the SSH key → user
689 - let key_id: db::SshKeyId = key_id_str
690 - .parse()
691 - .map_err(|_| anyhow::anyhow!("invalid key ID"))?;
692 -
693 - let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
694 - .await?
695 - .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?;
696 -
697 - // Dispatch: git operations start with "git-", everything else is a management command
698 - if original_cmd.starts_with("git-") {
699 - exec_git_operation(pool, user_id, &original_cmd).await
700 - } else {
701 - exec_management_command(pool, user_id, &ssh_username, &original_cmd).await
702 - }
703 - }
704 -
705 - // ── Git operations ──
706 -
707 - #[derive(Debug)]
708 - enum GitOperation {
709 - UploadPack,
710 - ReceivePack,
711 - Archive,
712 - }
713 -
714 - async fn exec_git_operation(
715 - pool: &PgPool,
716 - user_id: db::UserId,
717 - original_cmd: &str,
718 - ) -> anyhow::Result<()> {
719 - let (operation, repo_path) = parse_ssh_command(original_cmd)?;
720 - let (owner, repo_name) = parse_repo_path(&repo_path)?;
721 -
722 - let owner_user = db::users::get_user_by_username(pool, &Username::from_trusted(owner.to_string()))
723 - .await?
724 - .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
725 -
726 - let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, &repo_name).await? {
727 - Some(repo) => repo,
728 - None => {
729 - // Auto-create on push if the authenticated user owns the namespace
730 - if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id {
731 - anyhow::bail!("repository not found");
732 - }
733 -
734 - let git_root = std::env::var("GIT_REPOS_PATH")
735 - .unwrap_or_else(|_| "/opt/git".to_string());
736 - let owner_dir = std::path::Path::new(&git_root).join(owner);
737 - let repo_dir = owner_dir.join(format!("{repo_name}.git"));
738 -
739 - std::fs::create_dir_all(&owner_dir)?;
740 - git2::Repository::init_bare(&repo_dir)?;
741 -
742 - // Install post-receive hook if build trigger token is configured
743 - if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") {
744 - let hook_content = makenotwork::build_runner::post_receive_hook(&token);
745 - install_hook_for_repo(&repo_dir, &hook_content)?;
746 - }
747 -
748 - // Fix ownership so the git user can write
749 - let status = std::process::Command::new("chown")
750 - .args(["-R", "git:git"])
751 - .arg(&repo_dir)
752 - .status()?;
753 - if !status.success() {
754 - anyhow::bail!("chown failed on {}", repo_dir.display());
755 - }
756 -
757 - eprintln!("Auto-created repository {}/{}", owner, repo_name);
758 - db::git_repos::create_repo(pool, owner_user.id, &repo_name).await?
759 - }
760 - };
761 -
762 - // Permission check
763 - match operation {
764 - GitOperation::ReceivePack => {
765 - if user_id != owner_user.id {
766 - anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
767 - }
768 - }
769 - GitOperation::UploadPack | GitOperation::Archive => {
770 - if repo.visibility == db::Visibility::Private && user_id != owner_user.id {
771 - anyhow::bail!("repository not found");
772 - }
773 - }
774 - }
775 -
776 - // Authorized — exec git-shell
777 - let err = exec_git_shell(original_cmd);
778 - anyhow::bail!("failed to exec git-shell: {}", err);
779 - }
780 -
781 - fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
782 - let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
783 - if parts.len() != 2 {
784 - anyhow::bail!("invalid git command");
785 - }
786 -
787 - let operation = match parts[0] {
788 - "git-upload-pack" => GitOperation::UploadPack,
789 - "git-receive-pack" => GitOperation::ReceivePack,
790 - "git-upload-archive" => GitOperation::Archive,
791 - _ => anyhow::bail!("unsupported git command: {}", parts[0]),
792 - };
793 -
794 - let repo_path = parts[1].trim_matches('\'').trim_matches('"');
795 - Ok((operation, repo_path.to_string()))
796 - }
797 -
798 - fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> {
799 - let path = path.trim_start_matches('/');
800 - let parts: Vec<&str> = path.splitn(2, '/').collect();
801 - if parts.len() != 2 {
802 - anyhow::bail!("invalid repository path");
803 - }
804 -
805 - let owner = parts[0];
806 - let mut repo_name = parts[1].to_string();
807 -
808 - if owner.contains("..") || repo_name.contains("..") {
809 - anyhow::bail!("invalid repository path");
810 - }
811 -
812 - if repo_name.ends_with(".git") {
813 - repo_name.truncate(repo_name.len() - 4);
814 - }
815 -
816 - if owner.is_empty() || repo_name.is_empty() {
817 - anyhow::bail!("invalid repository path");
818 - }
819 -
820 - Ok((owner, repo_name))
821 - }
822 -
823 - /// Replace the current process with git-shell.
824 - fn exec_git_shell(original_cmd: &str) -> std::io::Error {
825 - use std::os::unix::process::CommandExt;
826 - std::process::Command::new("git-shell")
827 - .args(["-c", original_cmd])
828 - .exec()
829 - }
830 -
831 - // ── SSH management commands ──
832 -
833 - #[derive(Debug, PartialEq)]
834 - enum ManagementCommand {
835 - RepoList,
836 - RepoInfo { name: String },
837 - RepoDelete { name: String },
838 - RepoSetVisibility { name: String, visibility: db::Visibility },
839 - RepoSetDescription { name: String, description: String },
840 - KeyList,
841 - KeyRemove { fingerprint: String },
842 - }
843 -
844 - /// Split a command string on whitespace, respecting double-quoted segments.
845 - #[allow(clippy::collapsible_if, clippy::collapsible_else_if)] // Collapsing would change else-branch fallthrough
846 - fn shell_tokenize(input: &str) -> Vec<String> {
847 - let mut tokens = Vec::new();
848 - let mut current = String::new();
849 - let mut in_quotes = false;
850 -
851 - for ch in input.chars() {
852 - if in_quotes {
853 - if ch == '"' {
854 - in_quotes = false;
855 - } else {
856 - current.push(ch);
857 - }
858 - } else if ch == '"' {
859 - in_quotes = true;
860 - } else if ch.is_ascii_whitespace() {
861 - if !current.is_empty() {
862 - tokens.push(std::mem::take(&mut current));
863 - }
864 - } else {
865 - current.push(ch);
866 - }
867 - }
868 -
869 - if !current.is_empty() {
870 - tokens.push(current);
871 - }
872 -
873 - tokens
874 - }
875 -
876 - fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> {
877 - let strs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
878 -
879 - match strs.as_slice() {
880 - ["repo", "list"] => Ok(ManagementCommand::RepoList),
881 - ["repo", "info", name] => Ok(ManagementCommand::RepoInfo { name: name.to_string() }),
882 - ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }),
883 - ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"),
884 - ["repo", "set-visibility", name, vis] => {
885 - let visibility: db::Visibility = vis.parse()
886 - .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?;
887 - Ok(ManagementCommand::RepoSetVisibility {
888 - name: name.to_string(),
889 - visibility,
890 - })
891 - }
892 - ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription {
893 - name: name.to_string(),
894 - description: desc.to_string(),
895 - }),
896 - ["key", "list"] => Ok(ManagementCommand::KeyList),
897 - ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { fingerprint: fingerprint.to_string() }),
898 - _ => anyhow::bail!("unknown command. Available: repo list|info|delete|set-visibility|set-description, key list|rm"),
899 - }
900 - }
901 -
902 - async fn exec_management_command(
903 - pool: &PgPool,
904 - user_id: db::UserId,
905 - username: &str,
906 - original_cmd: &str,
907 - ) -> anyhow::Result<()> {
908 - let tokens = shell_tokenize(original_cmd);
909 - let cmd = parse_management_command(&tokens)?;
910 -
911 - match cmd {
912 - ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await,
913 - ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await,
914 - ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await,
915 - ManagementCommand::RepoSetVisibility { name, visibility } => {
916 - cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await
917 - }
918 - ManagementCommand::RepoSetDescription { name, description } => {
919 - cmd_ssh_repo_set_description(pool, user_id, &name, &description).await
920 - }
921 - ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await,
922 - ManagementCommand::KeyRemove { fingerprint } => cmd_ssh_key_remove(pool, user_id, &fingerprint).await,
923 - }
924 - }
925 -
926 - async fn cmd_ssh_repo_list(pool: &PgPool, user_id: db::UserId) -> anyhow::Result<()> {
927 - let repos = db::git_repos::get_repos_by_user(pool, user_id).await?;
928 -
929 - if repos.is_empty() {
930 - println!("No repositories.");
931 - return Ok(());
932 - }
933 -
934 - println!("{:<30} {:<10} Description", "Name", "Visibility");
935 - println!("{}", "-".repeat(70));
936 -
937 - for repo in &repos {
938 - let desc = if repo.description.is_empty() {
939 - "-".to_string()
940 - } else if repo.description.len() > 28 {
941 - format!("{}...", &repo.description[..25])
942 - } else {
943 - repo.description.clone()
944 - };
945 - println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc);
946 - }
947 -
948 - println!("\n{} repo(s).", repos.len());
949 - Ok(())
950 - }
951 -
952 - async fn cmd_ssh_repo_info(pool: &PgPool, user_id: db::UserId, name: &str) -> anyhow::Result<()> {
953 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
954 - .await?
955 - .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
956 -
957 - let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?;
958 -
959 - println!("Name: {}", repo.name);
960 - println!("Visibility: {}", repo.visibility);
961 - println!("Description: {}", if repo.description.is_empty() { "-" } else { &repo.description });
962 - println!("Created: {}", repo.created_at.format("%Y-%m-%d %H:%M UTC"));
963 - println!("Issues: {} open, {} closed", open_issues, closed_issues);
964 -
965 - Ok(())
966 - }
967 -
968 - async fn cmd_ssh_repo_delete(
969 - pool: &PgPool,
970 - user_id: db::UserId,
971 - username: &str,
972 - name: &str,
973 - ) -> anyhow::Result<()> {
974 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
975 - .await?
976 - .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
977 -
978 - // Delete from DB (issues cascade)
979 - db::git_repos::delete_repo(pool, repo.id).await?;
980 -
981 - // Delete from disk
982 - let git_root = std::env::var("GIT_REPOS_PATH")
983 - .unwrap_or_else(|_| "/opt/git".to_string());
984 - let repo_dir = std::path::Path::new(&git_root)
985 - .join(username)
986 - .join(format!("{}.git", name));
987 -
988 - if repo_dir.exists() {
989 - std::fs::remove_dir_all(&repo_dir)?;
990 - }
991 -
992 - println!("Deleted repository '{}'.", name);
993 - Ok(())
994 - }
995 -
996 - async fn cmd_ssh_repo_set_visibility(
997 - pool: &PgPool,
998 - user_id: db::UserId,
999 - name: &str,
1000 - visibility: db::Visibility,
1001 - ) -> anyhow::Result<()> {
1002 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
1003 - .await?
1004 - .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
1005 -
1006 - db::git_repos::update_visibility(pool, repo.id, visibility).await?;
1007 -
1008 - println!("Set visibility of '{}' to '{}'.", name, visibility);
1009 - Ok(())
1010 - }
1011 -
1012 - async fn cmd_ssh_repo_set_description(
1013 - pool: &PgPool,
1014 - user_id: db::UserId,
1015 - name: &str,
1016 - description: &str,
1017 - ) -> anyhow::Result<()> {
1018 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
1019 - .await?
1020 - .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
1021 -
1022 - db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?;
1023 -
1024 - println!("Updated description of '{}'.", name);
1025 - Ok(())
1026 - }
1027 -
1028 - async fn cmd_ssh_key_list(pool: &PgPool, user_id: db::UserId) -> anyhow::Result<()> {
1029 - let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?;
1030 -
1031 - if keys.is_empty() {
1032 - println!("No SSH keys.");
1033 - return Ok(());
1034 - }
1035 -
1036 - println!("{:<50} {:<20} Added", "Fingerprint", "Label");
1037 - println!("{}", "-".repeat(80));
1038 -
1039 - for key in &keys {
1040 - let label = if key.label.is_empty() { "-" } else { &key.label };
1041 - println!(
1042 - "{:<50} {:<20} {}",
1043 - key.fingerprint,
1044 - label,
1045 - key.created_at.format("%Y-%m-%d"),
1046 - );
1047 - }
1048 -
1049 - println!("\n{} key(s).", keys.len());
1050 - Ok(())
1051 - }
1052 -
1053 - async fn cmd_ssh_key_remove(pool: &PgPool, user_id: db::UserId, fingerprint: &str) -> anyhow::Result<()> {
1054 - let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?;
1055 -
1056 - if !deleted {
1057 - anyhow::bail!("SSH key with fingerprint '{}' not found", fingerprint);
1058 - }
1059 -
1060 - // Rebuild authorized_keys to remove the deleted key
1061 - write_authorized_keys(pool, true).await?;
1062 -
1063 - println!("Removed SSH key '{}'.", fingerprint);
1064 - Ok(())
1065 - }
1066 -
1067 - /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
1068 - async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
1069 - let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
1070 -
1071 - let mut content = String::new();
1072 - content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
1073 -
1074 - for key in &keys {
1075 - content.push_str(&format!(
1076 - "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n",
1077 - MNW_ADMIN_PATH, key.id, key.public_key,
1078 - ));
1079 - }
1080 -
1081 - let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH);
1082 - std::fs::write(&tmp_path, &content)?;
1083 - std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?;
1084 -
1085 - #[cfg(unix)]
1086 - {
1087 - use std::os::unix::fs::PermissionsExt;
1088 - std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?;
1089 -
1090 - if set_ownership {
1091 - let status = std::process::Command::new("chown")
1092 - .args(["git:git", AUTHORIZED_KEYS_PATH])
1093 - .status()?;
1094 - if !status.success() {
1095 - anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH);
1096 - }
1097 - }
1098 - }
1099 -
1100 - Ok(())
1101 - }
1102 -
1103 - #[cfg(test)]
1104 - mod tests {
1105 - use super::*;
1106 -
1107 - // ── shell_tokenize ──
1108 -
1109 - #[test]
1110 - fn tokenize_simple() {
1111 - assert_eq!(
1112 - shell_tokenize("repo list"),
1113 - vec!["repo", "list"],
1114 - );
Lines truncated
@@ -72,6 +72,17 @@ fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Opt
72 72 /// Called from the scheduler loop. Non-blocking — spawns the build task and returns.
73 73 #[tracing::instrument(skip_all, name = "build_runner::dispatch")]
74 74 pub async fn dispatch_pending_build(state: &AppState) {
75 + // Recover from stale running builds (e.g. server crashed mid-build)
76 + match db::builds::fail_stale_running_builds(&state.db, BUILD_TIMEOUT_SECS as i64).await {
77 + Ok(n) if n > 0 => {
78 + tracing::warn!(count = n, "marked stale running builds as failed");
79 + }
80 + Err(e) => {
81 + tracing::error!(error = ?e, "failed to check stale builds");
82 + }
83 + _ => {}
84 + }
85 +
75 86 let has_running = match db::builds::has_running_build(&state.db).await {
76 87 Ok(r) => r,
77 88 Err(e) => {
@@ -310,7 +321,16 @@ async fn execute_target(
310 321 .replace("{target}", rust_triple)
311 322 .replace("{version}", &build.version);
312 323
324 + // Validate build_command and artifact_path before interpolation into shell
325 + validate_build_command(&build_cmd)
326 + .map_err(|e| format!("invalid build command: {e}"))?;
327 + validate_artifact_path(&artifact_path)
328 + .map_err(|e| format!("invalid artifact path: {e}"))?;
329 +
313 330 // Build the SSH command sequence
331 + // Note: build_cmd is validated (no shell metacharacters beyond safe set) but
332 + // intentionally NOT shell-escaped since it must execute as a shell command.
333 + // artifact_path is validated AND shell-escaped since it's used as a file path.
314 334 let remote_script = format!(
315 335 "set -e && \
316 336 git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \
@@ -354,12 +374,12 @@ async fn execute_target(
354 374
355 375 // Copy artifact from remote to local temp
356 376 let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id);
357 - let scp_result = run_scp_download(
358 - host,
359 - &format!("{build_dir}/{artifact_path}"),
360 - &local_tmp,
361 - )
362 - .await;
377 + let scp_remote_path = format!(
378 + "{}/{}",
379 + build_dir.trim_end_matches('/'),
380 + artifact_path.trim_start_matches('/')
381 + );
382 + let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await;
363 383
364 384 // Cleanup remote build dir
365 385 let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await;
@@ -476,6 +496,82 @@ async fn append_log_bounded(
476 496 db::builds::append_build_log(&state.db, build_id, line).await
477 497 }
478 498
499 + /// Validate a build command for shell safety.
500 + ///
501 + /// Rejects shell metacharacters that enable command chaining or redirection.
502 + /// Allowed: alphanumeric, spaces, hyphens, underscores, dots, slashes, equals,
503 + /// braces (for template vars), colons, commas, plus signs.
504 + pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
505 + if cmd.is_empty() {
506 + return Err("build command is empty".to_string());
507 + }
508 + if cmd.len() > 1024 {
509 + return Err("build command too long (max 1024 chars)".to_string());
510 + }
511 + for (i, c) in cmd.chars().enumerate() {
512 + match c {
513 + 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
514 + ' ' | '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {}
515 + ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"'
516 + | '\n' | '\r' | '\0' => {
517 + return Err(format!(
518 + "shell metacharacter '{}' at position {} is not allowed",
519 + c.escape_default(),
520 + i
521 + ));
522 + }
523 + _ => {
524 + return Err(format!(
525 + "unexpected character '{}' at position {} is not allowed",
526 + c.escape_default(),
527 + i
528 + ));
529 + }
530 + }
531 + }
532 + Ok(())
533 + }
534 +
535 + /// Validate an artifact path for shell and path safety.
536 + ///
537 + /// Must be a relative path with no shell metacharacters or path traversal.
538 + pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> {
539 + if path.is_empty() {
540 + return Err("artifact path is empty".to_string());
541 + }
542 + if path.len() > 512 {
543 + return Err("artifact path too long (max 512 chars)".to_string());
544 + }
545 + if path.starts_with('/') {
546 + return Err("artifact path must be relative".to_string());
547 + }
548 + if path.contains("..") {
549 + return Err("artifact path must not contain '..'".to_string());
550 + }
551 + for (i, c) in path.chars().enumerate() {
552 + match c {
553 + 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
554 + '-' | '_' | '.' | '/' | '{' | '}' | '+' => {}
555 + ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"'
556 + | ' ' | '\n' | '\r' | '\0' => {
557 + return Err(format!(
558 + "character '{}' at position {} is not allowed in artifact path",
559 + c.escape_default(),
560 + i
561 + ));
562 + }
563 + _ => {
564 + return Err(format!(
565 + "unexpected character '{}' at position {} is not allowed in artifact path",
566 + c.escape_default(),
567 + i
568 + ));
569 + }
570 + }
571 + }
572 + Ok(())
573 + }
574 +
479 575 /// Escape a string for safe use in a shell command.
480 576 fn shell_escape(s: &str) -> String {
481 577 format!("'{}'", s.replace('\'', "'\\''"))
@@ -507,4 +603,37 @@ mod tests {
507 603 assert_eq!(shell_escape("hello"), "'hello'");
508 604 assert_eq!(shell_escape("it's"), "'it'\\''s'");
509 605 }
606 +
607 + #[test]
608 + fn validate_build_command_accepts_safe_commands() {
609 + assert!(validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok());
610 + assert!(validate_build_command("make -j4").is_ok());
611 + assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok());
612 + }
613 +
614 + #[test]
615 + fn validate_build_command_rejects_injection() {
616 + assert!(validate_build_command("cargo build; curl evil.com").is_err());
617 + assert!(validate_build_command("cargo build && rm -rf /").is_err());
618 + assert!(validate_build_command("cargo build | tee log").is_err());
619 + assert!(validate_build_command("$(whoami)").is_err());
620 + assert!(validate_build_command("`whoami`").is_err());
621 + assert!(validate_build_command("cargo build > /dev/null").is_err());
622 + assert!(validate_build_command("").is_err());
623 + }
624 +
625 + #[test]
626 + fn validate_artifact_path_accepts_safe_paths() {
627 + assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok());
628 + assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok());
629 + }
630 +
631 + #[test]
632 + fn validate_artifact_path_rejects_unsafe() {
633 + assert!(validate_artifact_path("/etc/passwd").is_err());
634 + assert!(validate_artifact_path("../../../etc/passwd").is_err());
635 + assert!(validate_artifact_path("path with spaces").is_err());
636 + assert!(validate_artifact_path("$(whoami)").is_err());
637 + assert!(validate_artifact_path("").is_err());
638 + }
510 639 }
@@ -199,3 +199,402 @@ pub const SANDBOX_RATE_LIMIT_MS: u64 = 30_000;
199 199 pub const SANDBOX_RATE_LIMIT_BURST: u32 = 2;
200 200 /// Max concurrent active sandboxes per IP.
201 201 pub const SANDBOX_MAX_PER_IP: i64 = 3;
202 +
203 + #[cfg(test)]
204 + mod tests {
205 + use super::*;
206 +
207 + // -- Price constants --
208 +
209 + #[test]
210 + fn max_price_cents_is_positive() {
211 + assert!(MAX_PRICE_CENTS > 0);
212 + }
213 +
214 + #[test]
215 + fn max_price_cents_sane_upper_bound() {
216 + // Should not exceed $100,000
217 + assert!(MAX_PRICE_CENTS <= 10_000_000);
218 + }
219 +
220 + #[test]
221 + fn min_subscription_price_positive() {
222 + assert!(MIN_SUBSCRIPTION_PRICE_CENTS > 0);
223 + }
224 +
225 + #[test]
226 + fn min_subscription_price_below_max() {
227 + assert!(MIN_SUBSCRIPTION_PRICE_CENTS < MAX_PRICE_CENTS);
228 + }
229 +
230 + // -- Stripe fee constants --
231 +
232 + #[test]
233 + fn stripe_fee_percentage_reasonable() {
234 + assert!(STRIPE_FEE_PERCENTAGE > 0.0);
235 + assert!(STRIPE_FEE_PERCENTAGE < 0.5); // less than 50%
236 + }
237 +
238 + #[test]
239 + fn stripe_fee_fixed_positive() {
240 + assert!(STRIPE_FEE_FIXED_CENTS > 0.0);
241 + }
242 +
243 + // -- Database pool --
244 +
245 + #[test]
246 + fn db_pool_max_exceeds_min() {
247 + assert!(DB_POOL_MAX_CONNECTIONS > DB_POOL_MIN_CONNECTIONS);
248 + }
249 +
250 + #[test]
251 + fn db_pool_min_positive() {
252 + assert!(DB_POOL_MIN_CONNECTIONS > 0);
253 + }
254 +
255 + #[test]
256 + fn db_acquire_timeout_positive() {
257 + assert!(DB_ACQUIRE_TIMEOUT_SECS > 0);
258 + }
259 +
260 + #[test]
261 + fn db_max_lifetime_exceeds_idle_timeout() {
262 + assert!(DB_MAX_LIFETIME_SECS > DB_IDLE_TIMEOUT_SECS);
263 + }
264 +
265 + // -- Session constants --
266 +
267 + #[test]
268 + fn session_expiry_positive() {
269 + assert!(SESSION_EXPIRY_DAYS > 0);
270 + }
271 +
272 + #[test]
273 + fn session_expiry_not_absurd() {
274 + assert!(SESSION_EXPIRY_DAYS <= 365);
275 + }
276 +
277 + #[test]
278 + fn session_touch_cache_positive() {
279 + assert!(SESSION_TOUCH_CACHE_SECS > 0);
280 + }
281 +
282 + #[test]
283 + fn session_touch_cache_less_than_one_day() {
284 + assert!(SESSION_TOUCH_CACHE_SECS < 86400);
285 + }
286 +
287 + // -- Login security --
288 +
289 + #[test]
290 + fn max_login_attempts_positive() {
291 + assert!(MAX_LOGIN_ATTEMPTS > 0);
292 + }
293 +
294 + #[test]
295 + fn lockout_minutes_positive() {
296 + assert!(LOCKOUT_MINUTES > 0);
297 + }
298 +
299 + // -- Email link expiry ordering --
300 +
301 + #[test]
302 + fn password_reset_expiry_positive() {
303 + assert!(PASSWORD_RESET_EXPIRY_SECS > 0);
304 + }
305 +
306 + #[test]
307 + fn email_verification_longer_than_password_reset() {
308 + assert!(EMAIL_VERIFICATION_EXPIRY_SECS > PASSWORD_RESET_EXPIRY_SECS);
309 + }
310 +
311 + #[test]
312 + fn account_deletion_expiry_positive() {
313 + assert!(ACCOUNT_DELETION_EXPIRY_SECS > 0);
314 + }
315 +
316 + // -- Scheduler --
317 +
318 + #[test]
319 + fn scheduler_interval_positive() {
320 + assert!(SCHEDULER_INTERVAL_SECS > 0);
321 + }
322 +
323 + // -- Rate limit bursts all positive --
324 +
325 + #[test]
326 + fn auth_rate_limit_burst_positive() {
327 + assert!(AUTH_RATE_LIMIT_BURST > 0);
328 + }
329 +
330 + #[test]
331 + fn validate_rate_limit_burst_positive() {
332 + assert!(VALIDATE_RATE_LIMIT_BURST > 0);
333 + }
334 +
335 + #[test]
336 + fn api_write_rate_limit_burst_positive() {
337 + assert!(API_WRITE_RATE_LIMIT_BURST > 0);
338 + }
339 +
340 + #[test]
341 + fn api_read_rate_limit_burst_positive() {
342 + assert!(API_READ_RATE_LIMIT_BURST > 0);
343 + }
344 +
345 + #[test]
346 + fn api_export_rate_limit_burst_positive() {
347 + assert!(API_EXPORT_RATE_LIMIT_BURST > 0);
348 + }
349 +
350 + #[test]
351 + fn license_key_rate_limit_burst_positive() {
352 + assert!(LICENSE_KEY_RATE_LIMIT_BURST > 0);
353 + }
354 +
355 + #[test]
356 + fn upload_rate_limit_burst_positive() {
357 + assert!(UPLOAD_RATE_LIMIT_BURST > 0);
358 + }
359 +
360 + #[test]
361 + fn oauth_rate_limit_burst_positive() {
362 + assert!(OAUTH_RATE_LIMIT_BURST > 0);
363 + }
364 +
365 + #[test]
366 + fn oauth_token_rate_limit_burst_positive() {
367 + assert!(OAUTH_TOKEN_RATE_LIMIT_BURST > 0);
368 + }
369 +
370 + // -- Rate limit burst ordering: read > write > auth --
371 +
372 + #[test]
373 + fn api_read_burst_exceeds_write_burst() {
374 + assert!(API_READ_RATE_LIMIT_BURST > API_WRITE_RATE_LIMIT_BURST);
375 + }
376 +
377 + #[test]
378 + fn api_write_burst_exceeds_auth_burst() {
379 + assert!(API_WRITE_RATE_LIMIT_BURST > AUTH_RATE_LIMIT_BURST);
380 + }
381 +
382 + // -- Rate limit intervals positive --
383 +
384 + #[test]
385 + fn auth_rate_limit_ms_positive() {
386 + assert!(AUTH_RATE_LIMIT_MS > 0);
387 + }
388 +
389 + #[test]
390 + fn api_write_rate_limit_ms_positive() {
391 + assert!(API_WRITE_RATE_LIMIT_MS > 0);
392 + }
393 +
394 + #[test]
395 + fn api_read_rate_limit_ms_positive() {
396 + assert!(API_READ_RATE_LIMIT_MS > 0);
397 + }
398 +
399 + // -- File size limits --
400 +
401 + #[test]
402 + fn scan_max_memory_positive() {
403 + assert!(SCAN_MAX_MEMORY_BYTES > 0);
404 + }
405 +
406 + #[test]
407 + fn scan_zip_max_uncompressed_exceeds_memory_threshold() {
408 + assert!(SCAN_ZIP_MAX_UNCOMPRESSED > SCAN_MAX_MEMORY_BYTES as u64);
409 + }
410 +
411 + #[test]
412 + fn git_raw_max_exceeds_file_display_limit() {
413 + assert!(GIT_RAW_MAX_BYTES > GIT_MAX_FILE_SIZE_BYTES);
414 + }
415 +
416 + #[test]
417 + fn scan_zip_max_ratio_positive() {
418 + assert!(SCAN_ZIP_MAX_RATIO > 0.0);
419 + }
420 +
421 + #[test]
422 + fn scan_zip_max_depth_positive() {
423 + assert!(SCAN_ZIP_MAX_DEPTH > 0);
424 + }
425 +
426 + // -- SyncKit --
427 +
428 + #[test]
429 + fn synckit_push_max_changes_positive() {
430 + assert!(SYNCKIT_PUSH_MAX_CHANGES > 0);
431 + }
432 +
433 + #[test]
434 + fn synckit_pull_page_size_positive() {
435 + assert!(SYNCKIT_PULL_PAGE_SIZE > 0);
436 + }
437 +
438 + #[test]
439 + fn synckit_max_blob_size_positive() {
440 + assert!(SYNCKIT_MAX_BLOB_SIZE_BYTES > 0);
441 + }
442 +
443 + #[test]
444 + fn synckit_jwt_expiry_positive() {
445 + assert!(SYNCKIT_JWT_EXPIRY_SECS > 0);
446 + }
447 +
448 + // -- TOTP --
449 +
450 + #[test]
451 + fn totp_digits_is_six() {
452 + assert_eq!(TOTP_DIGITS, 6);
453 + }
454 +
455 + #[test]
456 + fn totp_step_is_30() {
457 + assert_eq!(TOTP_STEP, 30);
458 + }
459 +
460 + #[test]
461 + fn backup_code_count_positive() {
462 + assert!(BACKUP_CODE_COUNT > 0);
463 + }
464 +
465 + #[test]
466 + fn backup_code_length_positive() {
467 + assert!(BACKUP_CODE_LENGTH > 0);
468 + }
469 +
470 + // -- Pagination --
471 +
472 + #[test]
473 + fn discover_page_size_positive() {
474 + assert!(DISCOVER_PAGE_SIZE > 0);
475 + }
476 +
477 + #[test]
478 + fn feed_page_size_positive() {
479 + assert!(FEED_PAGE_SIZE > 0);
480 + }
481 +
482 + #[test]
483 + fn pagination_window_size_positive() {
484 + assert!(PAGINATION_WINDOW_SIZE > 0);
485 + }
486 +
487 + // -- String constants non-empty --
488 +
489 + #[test]
490 + fn date_formats_non_empty() {
491 + assert!(!DATE_FMT_SHORT.is_empty());
492 + assert!(!DATE_FMT_FULL.is_empty());
493 + assert!(!DATE_FMT_ISO.is_empty());
494 + assert!(!DATE_FMT_DATETIME.is_empty());
495 + assert!(!DATE_FMT_DATETIME_UTC.is_empty());
496 + }
497 +
498 + #[test]
499 + fn changelog_project_slug_non_empty() {
500 + assert!(!CHANGELOG_PROJECT_SLUG.is_empty());
501 + }
502 +
503 + #[test]
504 + fn build_allowed_targets_non_empty() {
505 + assert!(!BUILD_ALLOWED_TARGETS.is_empty());
506 + for target in BUILD_ALLOWED_TARGETS {
507 + assert!(!target.is_empty());
508 + assert!(target.contains('/'), "target should be os/arch format: {}", target);
509 + }
510 + }
511 +
512 + // -- Collections --
513 +
514 + #[test]
515 + fn max_collections_per_user_positive() {
516 + assert!(MAX_COLLECTIONS_PER_USER > 0);
517 + }
518 +
519 + #[test]
520 + fn max_items_per_collection_positive() {
521 + assert!(MAX_ITEMS_PER_COLLECTION > 0);
522 + }
523 +
524 + // -- Build pipeline --
525 +
526 + #[test]
527 + fn build_timeout_positive() {
528 + assert!(BUILD_TIMEOUT_SECS > 0);
529 + }
530 +
531 + #[test]
532 + fn build_max_log_bytes_positive() {
533 + assert!(BUILD_MAX_LOG_BYTES > 0);
534 + }
535 +
536 + // -- Health monitoring --
537 +
538 + #[test]
539 + fn health_check_interval_positive() {
540 + assert!(HEALTH_CHECK_INTERVAL_SECS > 0);
541 + }
542 +
543 + #[test]
544 + fn alert_cooldown_exceeds_health_check() {
545 + assert!(ALERT_COOLDOWN_SECS > HEALTH_CHECK_INTERVAL_SECS);
546 + }
547 +
548 + // -- Sandbox --
549 +
550 + #[test]
551 + fn sandbox_expiry_positive() {
552 + assert!(SANDBOX_EXPIRY_SECS > 0);
553 + }
554 +
555 + #[test]
556 + fn sandbox_cleanup_interval_positive() {
557 + assert!(SANDBOX_CLEANUP_INTERVAL_SECS > 0);
558 + }
559 +
560 + #[test]
561 + fn sandbox_cleanup_less_than_expiry() {
562 + assert!(SANDBOX_CLEANUP_INTERVAL_SECS < SANDBOX_EXPIRY_SECS as u64);
563 + }
564 +
565 + #[test]
566 + fn sandbox_max_per_ip_positive() {
567 + assert!(SANDBOX_MAX_PER_IP > 0);
568 + }
569 +
570 + // -- Webhook --
571 +
572 + #[test]
573 + fn webhook_timestamp_tolerance_positive() {
574 + assert!(WEBHOOK_TIMESTAMP_TOLERANCE_SECS > 0);
575 + }
576 +
577 + // -- OAuth --
578 +
579 + #[test]
580 + fn oauth_code_expiry_positive() {
581 + assert!(OAUTH_CODE_EXPIRY_SECS > 0);
582 + }
583 +
584 + #[test]
585 + fn oauth_code_length_positive() {
586 + assert!(OAUTH_CODE_LENGTH > 0);
587 + }
588 +
589 + // -- Buffer limits --
590 +
591 + #[test]
592 + fn user_agent_max_length_positive() {
593 + assert!(USER_AGENT_MAX_LENGTH > 0);
594 + }
595 +
596 + #[test]
597 + fn synckit_max_key_envelope_bytes_positive() {
598 + assert!(SYNCKIT_MAX_KEY_ENVELOPE_BYTES > 0);
599 + }
600 + }
@@ -295,4 +295,108 @@ mod tests {
295 295 let token = extract_token_from_request(&headers, None);
296 296 assert!(token.is_none());
297 297 }
298 +
299 + #[test]
300 + fn test_generate_token_unique_across_many() {
301 + let tokens: Vec<String> = (0..100).map(|_| generate_token()).collect();
302 + let unique: std::collections::HashSet<&String> = tokens.iter().collect();
303 + assert_eq!(unique.len(), 100, "all 100 tokens should be unique");
304 + }
305 +
306 + #[test]
307 + fn test_generate_token_correct_byte_length() {
308 + let token = generate_token();
309 + let bytes = hex::decode(&token).expect("token should be valid hex");
310 + assert_eq!(bytes.len(), CSRF_TOKEN_LENGTH);
311 + }
312 +
313 + #[test]
314 + fn test_extract_token_header_takes_priority_over_body() {
315 + let mut headers = HeaderMap::new();
316 + headers.insert("X-CSRF-Token", "header_token".parse().unwrap());
317 + let body = "_csrf=body_token";
318 + let token = extract_token_from_request(&headers, Some(body));
319 + assert_eq!(token.as_deref(), Some("header_token"));
320 + }
321 +
322 + #[test]
323 + fn test_extract_token_from_body_url_encoded() {
324 + let headers = HeaderMap::new();
325 + let body = "_csrf=token%20with%20spaces&other=val";
326 + let token = extract_token_from_request(&headers, Some(body));
327 + assert_eq!(token.as_deref(), Some("token with spaces"));
328 + }
329 +
330 + #[test]
331 + fn test_extract_token_csrf_at_start_of_body() {
332 + let headers = HeaderMap::new();
333 + let body = "_csrf=firstfield&name=value";
334 + let token = extract_token_from_request(&headers, Some(body));
335 + assert_eq!(token.as_deref(), Some("firstfield"));
336 + }
337 +
338 + #[test]
339 + fn test_extract_token_csrf_at_end_of_body() {
340 + let headers = HeaderMap::new();
341 + let body = "name=value&_csrf=lastfield";
342 + let token = extract_token_from_request(&headers, Some(body));
343 + assert_eq!(token.as_deref(), Some("lastfield"));
344 + }
345 +
346 + #[test]
347 + fn test_extract_token_empty_body() {
348 + let headers = HeaderMap::new();
349 + let token = extract_token_from_request(&headers, Some(""));
350 + assert!(token.is_none());
351 + }
352 +
353 + #[test]
354 + fn test_extract_token_body_without_csrf_field() {
355 + let headers = HeaderMap::new();
356 + let body = "name=value&other=data";
357 + let token = extract_token_from_request(&headers, Some(body));
358 + assert!(token.is_none());
359 + }
360 +
361 + #[test]
362 + fn test_extract_token_csrf_prefix_mismatch() {
363 + let headers = HeaderMap::new();
364 + // Field named "_csrfx" should NOT match "_csrf="
365 + let body = "_csrfx=notreal";
366 + let token = extract_token_from_request(&headers, Some(body));
367 + assert!(token.is_none());
368 + }
369 +
370 + #[test]
371 + fn test_extract_token_empty_csrf_value() {
372 + let headers = HeaderMap::new();
373 + let body = "_csrf=&other=val";
374 + let token = extract_token_from_request(&headers, Some(body));
375 + assert_eq!(token.as_deref(), Some(""));
376 + }
377 +
378 + #[test]
379 + fn test_constant_time_compare_empty_strings() {
380 + use crate::helpers::constant_time_compare;
381 + assert!(constant_time_compare("", ""));
382 + }
383 +
384 + #[test]
385 + fn test_constant_time_compare_near_miss() {
386 + use crate::helpers::constant_time_compare;
387 + let token = generate_token();
388 + // Flip last character
389 + let mut tampered = token.clone();
390 + let last = tampered.pop().unwrap();
391 + tampered.push(if last == '0' { '1' } else { '0' });
392 + assert!(!constant_time_compare(&token, &tampered));
393 + }
394 +
395 + #[test]
396 + fn test_constant_time_compare_truncated() {
397 + use crate::helpers::constant_time_compare;
398 + let token = generate_token();
399 + let truncated = &token[..token.len() - 1];
400 + assert!(!constant_time_compare(&token, truncated));
401 + }
298 402 }
@@ -4,7 +4,7 @@ use chrono::{DateTime, Datelike, Utc};
4 4 use sqlx::PgPool;
5 5 use uuid::Uuid;
6 6
7 - use super::{FollowTargetType, ItemId, ProjectId, UserId};
7 + use super::{Cents, FollowTargetType, ItemId, ProjectId, UserId};
8 8 use crate::error::Result;
9 9
10 10 /// Time range for analytics queries.
@@ -63,14 +63,14 @@ impl TimeRange {
63 63 /// A single time bucket in a revenue timeseries.
64 64 pub struct TimeBucket {
65 65 pub label: String,
66 - pub revenue_cents: i64,
66 + pub revenue_cents: Cents,
67 67 pub sales_count: i64,
68 68 }
69 69
70 70 /// Period-over-period comparison data for stat cards.
71 71 pub struct PeriodComparison {
72 - pub current_revenue_cents: i64,
73 - pub previous_revenue_cents: i64,
72 + pub current_revenue_cents: Cents,
73 + pub previous_revenue_cents: Cents,
74 74 pub current_sales: i64,
75 75 pub previous_sales: i64,
76 76 pub current_followers: i64,
@@ -81,7 +81,7 @@ impl PeriodComparison {
81 81 /// Percentage change in revenue, e.g. `("+42%", true)`. None if no previous data.
82 82 #[tracing::instrument(skip_all)]
83 83 pub fn revenue_change(&self) -> Option<(String, bool)> {
84 - pct_change(self.current_revenue_cents, self.previous_revenue_cents)
84 + pct_change(self.current_revenue_cents.as_i64(), self.previous_revenue_cents.as_i64())
85 85 }
86 86
87 87 /// Percentage change in sales count.
@@ -291,7 +291,7 @@ pub async fn get_revenue_timeseries(
291 291 .into_iter()
292 292 .map(|(dt, revenue, count)| TimeBucket {
293 293 label: format_bucket_label(&dt, range),
294 - revenue_cents: revenue,
294 + revenue_cents: Cents::new(revenue),
295 295 sales_count: count,
296 296 })
297 297 .collect();
@@ -318,8 +318,8 @@ pub async fn get_period_comparison(
318 318 get_follower_comparison(pool, seller_id, project_id, item_id, range).await?;
319 319
320 320 Ok(PeriodComparison {
321 - current_revenue_cents: current_revenue,
322 - previous_revenue_cents: prev_revenue,
321 + current_revenue_cents: Cents::new(current_revenue),
322 + previous_revenue_cents: Cents::new(prev_revenue),
323 323 current_sales,
324 324 previous_sales: prev_sales,
325 325 current_followers,
@@ -596,8 +596,8 @@ mod tests {
596 596 #[test]
597 597 fn period_comparison_helpers() {
598 598 let pc = PeriodComparison {
599 - current_revenue_cents: 200,
600 - previous_revenue_cents: 100,
599 + current_revenue_cents: Cents::new(200),
600 + previous_revenue_cents: Cents::new(100),
601 601 current_sales: 10,
602 602 previous_sales: 20,
603 603 current_followers: 50,
@@ -90,6 +90,26 @@ pub async fn get_blog_posts_by_project(
90 90 Ok(posts)
91 91 }
92 92
93 + /// Batch-load blog posts for multiple projects, grouped by project_id.
94 + #[tracing::instrument(skip_all)]
95 + pub async fn get_blog_posts_by_projects(
96 + pool: &PgPool,
97 + project_ids: &[ProjectId],
98 + ) -> Result<std::collections::HashMap<ProjectId, Vec<DbBlogPost>>> {
99 + let posts = sqlx::query_as::<_, DbBlogPost>(
100 + "SELECT * FROM blog_posts WHERE project_id = ANY($1) ORDER BY project_id, created_at DESC",
101 + )
102 + .bind(project_ids)
103 + .fetch_all(pool)
104 + .await?;
105 +
106 + let mut map: std::collections::HashMap<ProjectId, Vec<DbBlogPost>> = std::collections::HashMap::new();
107 + for p in posts {
108 + map.entry(p.project_id).or_default().push(p);
109 + }
110 + Ok(map)
111 + }
112 +
93 113 /// List published blog posts in a project (for public pages), newest first.
94 114 #[tracing::instrument(skip_all)]
95 115 pub async fn get_published_blog_posts_by_project(
@@ -251,6 +251,28 @@ pub async fn update_build_status(
251 251 Ok(())
252 252 }
253 253
254 + /// Mark any builds that have been "running" longer than the timeout as failed.
255 + ///
256 + /// Returns the number of builds marked as failed.
257 + #[tracing::instrument(skip_all)]
258 + pub async fn fail_stale_running_builds(pool: &PgPool, timeout_secs: i64) -> Result<u64> {
259 + let result = sqlx::query(
260 + r#"
261 + UPDATE ota_builds
262 + SET status = 'failed',
263 + finished_at = now(),
264 + error_message = 'Build timed out (stale running status)'
265 + WHERE status = 'running'
266 + AND started_at < now() - make_interval(secs => $1)
267 + "#,
268 + )
269 + .bind(timeout_secs as f64)
270 + .execute(pool)
271 + .await?;
272 +
273 + Ok(result.rows_affected())
274 + }
275 +
254 276 /// Append a line to the build log.
255 277 #[tracing::instrument(skip_all)]
256 278 pub async fn append_build_log(pool: &PgPool, build_id: BuildId, line: &str) -> Result<()> {
@@ -157,13 +157,19 @@ pub async fn set_bundle_items(
157 157 .execute(&mut *tx)
158 158 .await?;
159 159
160 - for (i, item_id) in item_ids.iter().enumerate() {
160 + if !item_ids.is_empty() {
161 + let bundle_ids: Vec<ItemId> = vec![bundle_id; item_ids.len()];
162 + let orders: Vec<i32> = (0..item_ids.len() as i32).collect();
161 163 sqlx::query(
162 - "INSERT INTO bundle_items (bundle_id, item_id, sort_order) VALUES ($1, $2, $3)",
164 + r#"
165 + INSERT INTO bundle_items (bundle_id, item_id, sort_order)
166 + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::INT[])
167 + ON CONFLICT (bundle_id, item_id) DO UPDATE SET sort_order = EXCLUDED.sort_order
168 + "#,
163 169 )
164 - .bind(bundle_id)
165 - .bind(item_id)
166 - .bind(i as i32)
170 + .bind(&bundle_ids)
171 + .bind(item_ids)
172 + .bind(&orders)
167 173 .execute(&mut *tx)
168 174 .await?;
169 175 }
@@ -209,11 +215,35 @@ pub async fn get_project_bundle_map(
209 215 Ok(rows)
210 216 }
211 217
218 + /// Batch-load bundle maps for multiple projects at once.
219 + ///
220 + /// Returns (bundle_id, child_item_id) pairs for all bundles across the given projects.
221 + #[tracing::instrument(skip_all)]
222 + pub async fn get_bundle_maps_by_projects(
223 + pool: &PgPool,
224 + project_ids: &[super::ProjectId],
225 + ) -> Result<Vec<(ItemId, ItemId)>> {
226 + let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
227 + r#"
228 + SELECT bi.bundle_id, bi.item_id
229 + FROM bundle_items bi
230 + JOIN items i ON i.id = bi.bundle_id
231 + WHERE i.project_id = ANY($1)
232 + ORDER BY bi.bundle_id, bi.sort_order
233 + "#,
234 + )
235 + .bind(project_ids)
236 + .fetch_all(pool)
237 + .await?;
238 +
239 + Ok(rows)
240 + }
241 +
212 242 /// Check if an item is a member of a bundle.
213 243 #[tracing::instrument(skip_all)]
214 244 pub async fn is_bundle_member(pool: &PgPool, bundle_id: ItemId, child_id: ItemId) -> Result<bool> {
215 245 let exists: bool = sqlx::query_scalar(
216 - "SELECT EXISTS(SELECT 1 FROM bundle_items WHERE bundle_id = $1 AND child_item_id = $2)",
246 + "SELECT EXISTS(SELECT 1 FROM bundle_items WHERE bundle_id = $1 AND item_id = $2)",
217 247 )
218 248 .bind(bundle_id)
219 249 .bind(child_id)
@@ -21,6 +21,26 @@ pub async fn get_chapters_by_item(pool: &PgPool, item_id: ItemId) -> Result<Vec<
21 21 Ok(chapters)
22 22 }
23 23
24 + /// Batch-load chapters for multiple items, grouped by item_id.
25 + #[tracing::instrument(skip_all)]
26 + pub async fn get_chapters_by_items(
27 + pool: &PgPool,
28 + item_ids: &[ItemId],
29 + ) -> Result<std::collections::HashMap<ItemId, Vec<DbChapter>>> {
30 + let chapters = sqlx::query_as::<_, DbChapter>(
31 + "SELECT * FROM chapters WHERE item_id = ANY($1) ORDER BY item_id, sort_order, start_seconds",
32 + )
33 + .bind(item_ids)
34 + .fetch_all(pool)
35 + .await?;
36 +
37 + let mut map: std::collections::HashMap<ItemId, Vec<DbChapter>> = std::collections::HashMap::new();
38 + for ch in chapters {
39 + map.entry(ch.item_id).or_default().push(ch);
40 + }
41 + Ok(map)
42 + }
43 +
24 44 /// Insert a new chapter marker for an audio item.
25 45 #[tracing::instrument(skip_all)]
26 46 pub async fn create_chapter(
@@ -289,13 +289,22 @@ pub async fn get_grandfathered_until(
289 289 Ok(val)
290 290 }
291 291
292 - /// Recompute storage_used_bytes from versions + insertions + item audio/cover/video + media files.
293 - /// Returns the corrected total.
292 + /// Get a per-category storage breakdown for the creator dashboard (single query).
294 293 #[tracing::instrument(skip_all)]
295 - pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result<i64> {
296 - let total: i64 = sqlx::query_scalar(
294 + pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<StorageBreakdown> {
295 + let row: (i64, i64, i64, i64, i64, i64) = sqlx::query_as(
297 296 r#"
298 - WITH version_bytes AS (
297 + WITH audio_bytes AS (
298 + SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
299 + FROM items i JOIN projects p ON i.project_id = p.id
300 + WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
301 + ),
302 + cover_bytes AS (
303 + SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
304 + FROM items i JOIN projects p ON i.project_id = p.id
305 + WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
306 + ),
307 + version_bytes AS (
299 308 SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
300 309 FROM versions v
301 310 JOIN items i ON v.item_id = i.id
@@ -303,127 +312,39 @@ pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result<
303 312 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
304 313 ),
305 314 insertion_bytes AS (
306 - SELECT COALESCE(SUM(ci.file_size)::BIGINT, 0) AS total
307 - FROM content_insertions ci
308 - WHERE ci.user_id = $1
309 - ),
310 - audio_bytes AS (
311 - SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
312 - FROM items i
313 - JOIN projects p ON i.project_id = p.id
314 - WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
315 - ),
316 - cover_bytes AS (
317 - SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
318 - FROM items i
319 - JOIN projects p ON i.project_id = p.id
320 - WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
315 + SELECT COALESCE(SUM(file_size)::BIGINT, 0) AS total
316 + FROM content_insertions WHERE user_id = $1
321 317 ),
322 318 video_bytes AS (
323 319 SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) AS total
324 - FROM items i
325 - JOIN projects p ON i.project_id = p.id
320 + FROM items i JOIN projects p ON i.project_id = p.id
326 321 WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL
327 322 ),
328 323 media_bytes AS (
329 - SELECT COALESCE(SUM(mf.file_size_bytes)::BIGINT, 0) AS total
330 - FROM media_files mf
331 - WHERE mf.user_id = $1
324 + SELECT COALESCE(SUM(file_size_bytes)::BIGINT, 0) AS total
325 + FROM media_files WHERE user_id = $1
332 326 )
333 - SELECT ((SELECT total FROM version_bytes)
334 - + (SELECT total FROM insertion_bytes)
335 - + (SELECT total FROM audio_bytes)
336 - + (SELECT total FROM cover_bytes)
337 - + (SELECT total FROM video_bytes)
338 - + (SELECT total FROM media_bytes))::BIGINT AS total
339 - "#,
340 - )
341 - .bind(user_id)
342 - .fetch_one(pool)
343 - .await?;
344 -
345 - sqlx::query(
346 - "UPDATE users SET storage_used_bytes = $2 WHERE id = $1",
347 - )
348 - .bind(user_id)
349 - .bind(total)
350 - .execute(pool)
351 - .await?;
352 -
353 - Ok(total)
354 - }
355 -
356 - /// Get a per-category storage breakdown for the creator dashboard.
357 - #[tracing::instrument(skip_all)]
358 - pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<StorageBreakdown> {
359 - let audio: i64 = sqlx::query_scalar(
360 - r#"
361 - SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0)
362 - FROM items i JOIN projects p ON i.project_id = p.id
363 - WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
364 - "#,
365 - )
366 - .bind(user_id)
367 - .fetch_one(pool)
368 - .await?;
369 -
370 - let cover: i64 = sqlx::query_scalar(
371 - r#"
372 - SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0)
373 - FROM items i JOIN projects p ON i.project_id = p.id
374 - WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
375 - "#,
376 - )
377 - .bind(user_id)
378 - .fetch_one(pool)
379 - .await?;
380 -
381 - let download: i64 = sqlx::query_scalar(
382 - r#"
383 - SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0)
384 - FROM versions v
385 - JOIN items i ON v.item_id = i.id
386 - JOIN projects p ON i.project_id = p.id
387 - WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
388 - "#,
389 - )
390 - .bind(user_id)
391 - .fetch_one(pool)
392 - .await?;
393 -
394 - let insertion: i64 = sqlx::query_scalar(
395 - "SELECT COALESCE(SUM(file_size)::BIGINT, 0) FROM content_insertions WHERE user_id = $1",
396 - )
397 - .bind(user_id)
398 - .fetch_one(pool)
399 - .await?;
400 -
401 - let video: i64 = sqlx::query_scalar(
402 - r#"
403 - SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0)
404 - FROM items i JOIN projects p ON i.project_id = p.id
405 - WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL
327 + SELECT
328 + (SELECT total FROM audio_bytes),
329 + (SELECT total FROM cover_bytes),
330 + (SELECT total FROM version_bytes),
331 + (SELECT total FROM insertion_bytes),
332 + (SELECT total FROM video_bytes),
333 + (SELECT total FROM media_bytes)
406 334 "#,
407 335 )
408 336 .bind(user_id)
409 337 .fetch_one(pool)
410 338 .await?;
411 339
412 - let media: i64 = sqlx::query_scalar(
413 - "SELECT COALESCE(SUM(file_size_bytes)::BIGINT, 0) FROM media_files WHERE user_id = $1",
414 - )
415 - .bind(user_id)
416 - .fetch_one(pool)
417 - .await?;
418 -
419 340 Ok(StorageBreakdown {
420 - audio_bytes: audio,
421 - cover_bytes: cover,
422 - download_bytes: download,
423 - insertion_bytes: insertion,
424 - video_bytes: video,
425 - media_bytes: media,
426 - total_bytes: audio + cover + download + insertion + video + media,
341 + audio_bytes: row.0,
342 + cover_bytes: row.1,
343 + download_bytes: row.2,
344 + insertion_bytes: row.3,
345 + video_bytes: row.4,
346 + media_bytes: row.5,
347 + total_bytes: row.0 + row.1 + row.2 + row.3 + row.4 + row.5,
427 348 })
428 349 }
429 350
@@ -484,16 +405,61 @@ pub async fn is_in_grace_period(pool: &PgPool, user_id: UserId) -> Result<bool>
484 405 Ok(in_grace)
485 406 }
486 407
487 - /// Get all creator user IDs (for periodic storage recalculation).
408 + /// Batch-recalculate storage_used_bytes for ALL creators in a single query.
409 + ///
410 + /// Uses the same CTE logic as `recalculate_storage_used` but operates on all
411 + /// creator users at once, avoiding the N+1 loop. Returns the number of rows updated.
488 412 #[tracing::instrument(skip_all)]
489 - pub async fn get_all_creator_user_ids(pool: &PgPool) -> Result<Vec<UserId>> {
490 - let ids: Vec<UserId> = sqlx::query_scalar(
491 - "SELECT id FROM users WHERE can_create_projects = true",
413 + pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> {
414 + let result = sqlx::query(
415 + r#"
416 + UPDATE users SET storage_used_bytes = totals.total
417 + FROM (
418 + SELECT u.id AS user_id,
419 + COALESCE(audio.total, 0)
420 + + COALESCE(cover.total, 0)
421 + + COALESCE(video.total, 0)
422 + + COALESCE(versions.total, 0)
423 + + COALESCE(insertions.total, 0)
424 + + COALESCE(media.total, 0) AS total
425 + FROM users u
426 + LEFT JOIN LATERAL (
427 + SELECT SUM(i.audio_file_size_bytes)::BIGINT AS total
428 + FROM items i JOIN projects p ON i.project_id = p.id
429 + WHERE p.user_id = u.id AND i.audio_file_size_bytes IS NOT NULL
430 + ) audio ON true
431 + LEFT JOIN LATERAL (
432 + SELECT SUM(i.cover_file_size_bytes)::BIGINT AS total
433 + FROM items i JOIN projects p ON i.project_id = p.id
434 + WHERE p.user_id = u.id AND i.cover_file_size_bytes IS NOT NULL
435 + ) cover ON true
436 + LEFT JOIN LATERAL (
437 + SELECT SUM(i.video_file_size_bytes)::BIGINT AS total
438 + FROM items i JOIN projects p ON i.project_id = p.id
439 + WHERE p.user_id = u.id AND i.video_file_size_bytes IS NOT NULL
440 + ) video ON true
441 + LEFT JOIN LATERAL (
442 + SELECT SUM(v.file_size_bytes)::BIGINT AS total
443 + FROM versions v JOIN items i ON v.item_id = i.id JOIN projects p ON i.project_id = p.id
444 + WHERE p.user_id = u.id AND v.file_size_bytes IS NOT NULL
445 + ) versions ON true
446 + LEFT JOIN LATERAL (
447 + SELECT SUM(ci.file_size)::BIGINT AS total
448 + FROM content_insertions ci WHERE ci.user_id = u.id
449 + ) insertions ON true
450 + LEFT JOIN LATERAL (
451 + SELECT SUM(mf.file_size_bytes)::BIGINT AS total
452 + FROM media_files mf WHERE mf.user_id = u.id
453 + ) media ON true
454 + WHERE u.can_create_projects = true
455 + ) totals
456 + WHERE users.id = totals.user_id AND users.storage_used_bytes IS DISTINCT FROM totals.total
457 + "#,
492 458 )
493 - .fetch_all(pool)
459 + .execute(pool)
494 460 .await?;
495 461
496 - Ok(ids)
462 + Ok(result.rows_affected())
497 463 }
498 464
499 465 // ============================================================================
@@ -658,3 +624,287 @@ pub async fn check_presign_allowed(
658 624
659 625 Ok(())
660 626 }
627 +
628 + #[cfg(test)]
629 + mod tests {
630 + use super::*;
631 +
632 + // ── CreatorTier::label ───────────────────────────────────────────────
633 +
634 + #[test]
635 + fn label_basic() {
636 + assert_eq!(CreatorTier::Basic.label(), "Basic");
637 + }
638 +
639 + #[test]
640 + fn label_small_files() {
641 + assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
642 + }
643 +
644 + #[test]
645 + fn label_big_files() {
646 + assert_eq!(CreatorTier::BigFiles.label(), "Big Files");
647 + }
648 +
649 + #[test]
650 + fn label_everything() {
651 + assert_eq!(CreatorTier::Everything.label(), "Everything");
652 + }
653 +
654 + // ── CreatorTier::price_cents ────────────────────────────────────────
655 +
656 + #[test]
657 + fn price_basic_is_ten_dollars() {
658 + assert_eq!(CreatorTier::Basic.price_cents(), 1000);
659 + }
660 +
661 + #[test]
662 + fn price_small_files_is_twenty_dollars() {
663 + assert_eq!(CreatorTier::SmallFiles.price_cents(), 2000);
664 + }
665 +
666 + #[test]
667 + fn price_big_files_is_thirty_dollars() {
668 + assert_eq!(CreatorTier::BigFiles.price_cents(), 3000);
669 + }
670 +
671 + #[test]
672 + fn price_everything_is_forty_dollars() {
673 + assert_eq!(CreatorTier::Everything.price_cents(), 4000);
674 + }
675 +
676 + #[test]
677 + fn prices_are_strictly_increasing() {
678 + let tiers = [
679 + CreatorTier::Basic,
680 + CreatorTier::SmallFiles,
681 + CreatorTier::BigFiles,
682 + CreatorTier::Everything,
683 + ];
684 + for pair in tiers.windows(2) {
685 + assert!(
686 + pair[0].price_cents() < pair[1].price_cents(),
687 + "{:?} should cost less than {:?}",
688 + pair[0],
689 + pair[1],
690 + );
691 + }
692 + }
693 +
694 + // ── CreatorTier::max_file_bytes ─────────────────────────────────────
695 +
696 + #[test]
697 + fn max_file_basic_is_10mb() {
698 + assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024);
699 + }
700 +
701 + #[test]
702 + fn max_file_small_files_is_500mb() {
703 + assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024);
704 + }
705 +
706 + #[test]
707 + fn max_file_big_files_is_20gb() {
708 + assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024);
709 + }
710 +
711 + #[test]
712 + fn max_file_everything_matches_big_files() {
713 + assert_eq!(
714 + CreatorTier::Everything.max_file_bytes(),
715 + CreatorTier::BigFiles.max_file_bytes(),
716 + );
717 + }
718 +
719 + #[test]
720 + fn max_file_bytes_non_decreasing() {
721 + let tiers = [
722 + CreatorTier::Basic,
723 + CreatorTier::SmallFiles,
724 + CreatorTier::BigFiles,
725 + CreatorTier::Everything,
726 + ];
727 + for pair in tiers.windows(2) {
728 + assert!(
729 + pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
730 + "{:?} file limit should not exceed {:?}",
731 + pair[0],
732 + pair[1],
733 + );
734 + }
735 + }
736 +
737 + // ── CreatorTier::max_storage_bytes ───────────────────────────────────
738 +
739 + #[test]
740 + fn max_storage_basic_is_50gb() {
741 + assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
742 + }
743 +
744 + #[test]
745 + fn max_storage_small_files_is_250gb() {
746 + assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
747 + }
748 +
749 + #[test]
750 + fn max_storage_big_files_is_500gb() {
751 + assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
752 + }
753 +
754 + #[test]
755 + fn max_storage_everything_matches_big_files() {
756 + assert_eq!(
757 + CreatorTier::Everything.max_storage_bytes(),
758 + CreatorTier::BigFiles.max_storage_bytes(),
759 + );
760 + }
761 +
762 + #[test]
763 + fn max_storage_non_decreasing() {
764 + let tiers = [
765 + CreatorTier::Basic,
766 + CreatorTier::SmallFiles,
767 + CreatorTier::BigFiles,
768 + CreatorTier::Everything,
769 + ];
770 + for pair in tiers.windows(2) {
771 + assert!(
772 + pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
773 + "{:?} storage limit should not exceed {:?}",
774 + pair[0],
775 + pair[1],
776 + );
777 + }
778 + }
779 +
780 + // ── CreatorTier::allows_file_uploads ─────────────────────────────────
781 +
782 + #[test]
783 + fn basic_tier_disallows_file_uploads() {
784 + assert!(!CreatorTier::Basic.allows_file_uploads());
785 + }
786 +
787 + #[test]
788 + fn small_files_allows_file_uploads() {
789 + assert!(CreatorTier::SmallFiles.allows_file_uploads());
790 + }
791 +
792 + #[test]
793 + fn big_files_allows_file_uploads() {
794 + assert!(CreatorTier::BigFiles.allows_file_uploads());
795 + }
796 +
797 + #[test]
798 + fn everything_allows_file_uploads() {
799 + assert!(CreatorTier::Everything.allows_file_uploads());
800 + }
801 +
802 + // ── format_bytes helper ─────────────────────────────────────────────
803 +
804 + #[test]
805 + fn format_bytes_zero() {
806 + assert_eq!(format_bytes(0), "0 B");
807 + }
808 +
809 + #[test]
810 + fn format_bytes_one_byte() {
811 + assert_eq!(format_bytes(1), "1 B");
812 + }
813 +
814 + #[test]
815 + fn format_bytes_below_kb() {
816 + assert_eq!(format_bytes(1023), "1023 B");
817 + }
818 +
819 + #[test]
820 + fn format_bytes_exactly_1kb() {
821 + assert_eq!(format_bytes(1024), "1.0 KB");
822 + }
823 +
824 + #[test]
825 + fn format_bytes_exactly_1mb() {
826 + assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
827 + }
828 +
829 + #[test]
830 + fn format_bytes_exactly_1gb() {
831 + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
832 + }
833 +
834 + #[test]
835 + fn format_bytes_negative_clamped_to_zero() {
836 + assert_eq!(format_bytes(-999), "0 B");
837 + }
838 +
839 + #[test]
840 + fn format_bytes_large_storage_cap() {
841 + // 500 GB (Everything tier cap)
842 + assert_eq!(format_bytes(500 * 1024 * 1024 * 1024), "500.0 GB");
843 + }
844 +
845 + // ── StorageBreakdown ────────────────────────────────────────────────
846 +
847 + #[test]
848 + fn storage_breakdown_default_is_all_zeros() {
849 + let sb = StorageBreakdown::default();
850 + assert_eq!(sb.audio_bytes, 0);
851 + assert_eq!(sb.cover_bytes, 0);
852 + assert_eq!(sb.download_bytes, 0);
853 + assert_eq!(sb.insertion_bytes, 0);
854 + assert_eq!(sb.video_bytes, 0);
855 + assert_eq!(sb.media_bytes, 0);
856 + assert_eq!(sb.total_bytes, 0);
857 + }
858 +
859 + #[test]
860 + fn storage_breakdown_total_is_sum_of_categories() {
861 + let sb = StorageBreakdown {
862 + audio_bytes: 100,
863 + cover_bytes: 200,
864 + download_bytes: 300,
865 + insertion_bytes: 400,
866 + video_bytes: 500,
867 + media_bytes: 600,
868 + total_bytes: 100 + 200 + 300 + 400 + 500 + 600,
869 + };
870 + assert_eq!(
871 + sb.total_bytes,
872 + sb.audio_bytes + sb.cover_bytes + sb.download_bytes
873 + + sb.insertion_bytes + sb.video_bytes + sb.media_bytes,
874 + );
875 + }
876 +
877 + #[test]
878 + fn storage_breakdown_single_category() {
879 + let sb = StorageBreakdown {
880 + audio_bytes: 1_000_000,
881 + total_bytes: 1_000_000,
882 + ..Default::default()
883 + };
Lines truncated
@@ -29,28 +29,22 @@ pub struct DiscoverFilters<'a> {
29 29
30 30 const ITEM_SEARCH_CLAUSE: &str = r#" AND (
31 31 i.title % $1
32 - OR COALESCE(i.description, '') % $1
33 32 OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
34 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
35 33 )"#;
36 34
37 35 const PROJECT_SEARCH_CLAUSE: &str = r#" AND (
38 36 p.title % $1
39 - OR COALESCE(p.description, '') % $1
40 37 OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
41 - OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
42 38 )"#;
43 39
44 40 // ILIKE-only variants for short queries (1-2 chars) where trigram similarity is unreliable.
45 41
46 42 const ITEM_SEARCH_CLAUSE_SHORT: &str = r#" AND (
47 43 i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
48 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
49 44 )"#;
50 45
51 46 const PROJECT_SEARCH_CLAUSE_SHORT: &str = r#" AND (
52 47 p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
53 - OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
54 48 )"#;
55 49
56 50 /// Maximum allowed search term length. Queries longer than this are truncated.
@@ -227,7 +227,6 @@ pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result<
227 227 /// Export all followers of a user (direct user followers + project followers).
228 228 ///
229 229 /// Returns username, display_name, what they follow (user/project), and when.
230 - /// Capped at 10,000 rows.
231 230 #[tracing::instrument(skip_all)]
232 231 pub async fn get_followers_for_export(
233 232 pool: &PgPool,
@@ -256,7 +255,6 @@ pub async fn get_followers_for_export(
256 255 WHERE (f.target_type = 'user' AND f.target_id = $1)
257 256 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
258 257 ORDER BY f.created_at DESC
259 - LIMIT 10000
260 258 "#,
261 259 )
262 260 .bind(user_id)
@@ -289,7 +287,7 @@ pub async fn get_follower_emails(
289 287 JOIN users u ON u.id = f.follower_id
290 288 WHERE u.email_verified = true
291 289 AND u.suspended_at IS NULL
292 - AND LOWER(u.email) NOT IN (SELECT LOWER(email) FROM email_suppressions)
290 + AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
293 291 AND (
294 292 (f.target_type = 'user' AND f.target_id = $1)
295 293 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
@@ -337,7 +335,7 @@ pub async fn get_broadcast_follower_count(
337 335 JOIN users u ON u.id = f.follower_id
338 336 WHERE u.email_verified = true
339 337 AND u.suspended_at IS NULL
340 - AND LOWER(u.email) NOT IN (SELECT LOWER(email) FROM email_suppressions)
338 + AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
341 339 AND (
342 340 (f.target_type = 'user' AND f.target_id = $1)
343 341 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
@@ -120,6 +120,24 @@ pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<
120 120 Ok(rows)
121 121 }
122 122
123 + /// Fetch project_id for a batch of item IDs. Returns (item_id, project_id) pairs.
124 + ///
125 + /// Used by bulk operations to verify all items belong to the same project in one query.
126 + #[tracing::instrument(skip_all)]
127 + pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, super::ProjectId)>> {
128 + if ids.is_empty() {
129 + return Ok(vec![]);
130 + }
131 + let rows: Vec<(ItemId, super::ProjectId)> = sqlx::query_as(
132 + "SELECT id, project_id FROM items WHERE id = ANY($1)",
133 + )
134 + .bind(ids)
135 + .fetch_all(pool)
136 + .await?;
137 +
138 + Ok(rows)
139 + }
140 +
123 141 /// List all items in a project, ordered by sort_order then newest.
124 142 ///
125 143 /// Capped at 500 as a safety limit.
@@ -633,21 +651,26 @@ pub async fn move_item(
633 651 _ => return Ok(()),
634 652 };
635 653
636 - // Normalize all sort_orders, swapping the target pair
654 + // Normalize all sort_orders, swapping the target pair (single batch UPDATE)
655 + let mut ids = Vec::with_capacity(item_ids.len());
656 + let mut orders = Vec::with_capacity(item_ids.len());
637 657 for (i, id) in item_ids.iter().enumerate() {
638 - let new_order = if i == pos {
658 + ids.push(*id);
659 + orders.push(if i == pos {
639 660 swap_pos as i32
640 661 } else if i == swap_pos {
641 662 pos as i32
642 663 } else {
643 664 i as i32
644 - };
645 - sqlx::query("UPDATE items SET sort_order = $1 WHERE id = $2")
646 - .bind(new_order)
647 - .bind(id)
648 - .execute(&mut *tx)
649 - .await?;
665 + });
650 666 }
667 + sqlx::query(
668 + "UPDATE items SET sort_order = batch.ord FROM UNNEST($1::UUID[], $2::INT[]) AS batch(id, ord) WHERE items.id = batch.id",
669 + )
670 + .bind(&ids)
671 + .bind(&orders)
672 + .execute(&mut *tx)
673 + .await?;
651 674
652 675 tx.commit().await?;
653 676 Ok(())
@@ -91,6 +91,26 @@ pub async fn get_license_keys_by_item(pool: &PgPool, item_id: ItemId) -> Result<
91 91 Ok(keys)
92 92 }
93 93
94 + /// Batch-load license keys for multiple items, grouped by item_id.
95 + #[tracing::instrument(skip_all)]
96 + pub async fn get_license_keys_by_items(
97 + pool: &PgPool,
98 + item_ids: &[ItemId],
99 + ) -> Result<std::collections::HashMap<ItemId, Vec<DbLicenseKey>>> {
100 + let keys = sqlx::query_as::<_, DbLicenseKey>(
101 + "SELECT * FROM license_keys WHERE item_id = ANY($1) ORDER BY item_id, created_at DESC",
102 + )
103 + .bind(item_ids)
104 + .fetch_all(pool)
105 + .await?;
106 +
107 + let mut map: std::collections::HashMap<ItemId, Vec<DbLicenseKey>> = std::collections::HashMap::new();
108 + for k in keys {
109 + map.entry(k.item_id).or_default().push(k);
110 + }
111 + Ok(map)
112 + }
113 +
94 114 /// Find an existing activation for a key + machine combo.
95 115 #[tracing::instrument(skip_all)]
96 116 pub async fn get_activation(
@@ -396,4 +396,141 @@ mod tests {
396 396 item.video_s3_key = Some("video/test.mp4".to_string());
397 397 assert!(item.has_s3_content());
398 398 }
399 +
400 + #[test]
401 + fn has_s3_content_cover_only() {
402 + let mut item = make_item(super::super::super::ItemType::Audio);
403 + item.audio_s3_key = None;
404 + item.video_s3_key = None;
405 + // cover_s3_key is still Some from make_item
406 + assert!(item.has_s3_content());
407 + }
408 +
409 + #[test]
410 + fn has_s3_content_all_none() {
411 + let mut item = make_item(super::super::super::ItemType::Digital);
412 + item.audio_s3_key = None;
413 + item.cover_s3_key = None;
414 + item.video_s3_key = None;
415 + assert!(!item.has_s3_content());
416 + }
417 +
418 + #[test]
419 + fn content_other_for_all_non_media_types() {
420 + for item_type in [
421 + super::super::super::ItemType::Image,
422 + super::super::super::ItemType::Plugin,
423 + super::super::super::ItemType::Preset,
424 + super::super::super::ItemType::Sample,
425 + super::super::super::ItemType::Course,
426 + super::super::super::ItemType::Template,
427 + super::super::super::ItemType::Digital,
428 + ] {
429 + let item = make_item(item_type);
430 + assert!(
431 + matches!(item.content(), ContentData::Other),
432 + "expected Other for {:?}",
433 + item_type
434 + );
435 + }
436 + }
437 +
438 + #[test]
439 + fn content_text_with_none_fields() {
440 + let mut item = make_item(super::super::super::ItemType::Text);
441 + item.body = None;
442 + item.word_count = None;
443 + item.reading_time_minutes = None;
444 + match item.content() {
445 + ContentData::Text { body, word_count, reading_time_minutes } => {
446 + assert!(body.is_none());
447 + assert!(word_count.is_none());
448 + assert!(reading_time_minutes.is_none());
449 + }
450 + _ => panic!("expected Text variant"),
451 + }
452 + }
453 +
454 + #[test]
455 + fn content_audio_with_none_fields() {
456 + let mut item = make_item(super::super::super::ItemType::Audio);
457 + item.audio_url = None;
458 + item.audio_s3_key = None;
459 + item.cover_s3_key = None;
460 + item.cover_image_url = None;
461 + item.duration_seconds = None;
462 + item.episode_number = None;
463 + match item.content() {
464 + ContentData::Audio {
465 + audio_url,
466 + audio_s3_key,
467 + cover_s3_key,
468 + cover_image_url,
469 + duration_seconds,
470 + episode_number,
471 + } => {
472 + assert!(audio_url.is_none());
473 + assert!(audio_s3_key.is_none());
474 + assert!(cover_s3_key.is_none());
475 + assert!(cover_image_url.is_none());
476 + assert!(duration_seconds.is_none());
477 + assert!(episode_number.is_none());
478 + }
479 + _ => panic!("expected Audio variant"),
480 + }
481 + }
482 +
483 + #[test]
484 + fn content_video_with_none_fields() {
485 + let mut item = make_item(super::super::super::ItemType::Video);
486 + item.video_s3_key = None;
487 + item.cover_s3_key = None;
488 + item.cover_image_url = None;
489 + item.video_duration_seconds = None;
490 + item.video_width = None;
491 + item.video_height = None;
492 + match item.content() {
493 + ContentData::Video {
494 + video_s3_key,
495 + cover_s3_key,
496 + cover_image_url,
497 + duration_seconds,
498 + width,
499 + height,
500 + } => {
501 + assert!(video_s3_key.is_none());
502 + assert!(cover_s3_key.is_none());
503 + assert!(cover_image_url.is_none());
504 + assert!(duration_seconds.is_none());
505 + assert!(width.is_none());
506 + assert!(height.is_none());
507 + }
508 + _ => panic!("expected Video variant"),
509 + }
510 + }
511 +
512 + #[test]
513 + fn content_video_uses_video_duration_not_audio_duration() {
514 + let mut item = make_item(super::super::super::ItemType::Video);
515 + item.duration_seconds = Some(999); // audio duration
516 + item.video_duration_seconds = Some(42); // video duration
517 + match item.content() {
518 + ContentData::Video { duration_seconds, .. } => {
519 + assert_eq!(duration_seconds, Some(42));
520 + }
521 + _ => panic!("expected Video variant"),
522 + }
523 + }
524 +
525 + #[test]
526 + fn storage_breakdown_default_is_zero() {
527 + let sb = StorageBreakdown::default();
528 + assert_eq!(sb.audio_bytes, 0);
529 + assert_eq!(sb.cover_bytes, 0);
530 + assert_eq!(sb.download_bytes, 0);
531 + assert_eq!(sb.insertion_bytes, 0);
532 + assert_eq!(sb.video_bytes, 0);
533 + assert_eq!(sb.media_bytes, 0);
534 + assert_eq!(sb.total_bytes, 0);
535 + }
399 536 }
@@ -4,13 +4,14 @@ use chrono::{DateTime, Utc};
4 4 use sqlx::FromRow;
5 5
6 6 use super::super::id_types::*;
7 + use super::super::validated_types::Cents;
7 8
8 9 /// A revenue split record with context for CSV export.
9 10 #[derive(Debug, Clone, FromRow)]
10 11 pub struct DbSplitExportRow {
11 12 pub id: RevenueSplitId,
12 13 pub recipient_id: UserId,
13 - pub amount_cents: i32,
14 + pub amount_cents: Cents,
14 15 pub split_percent: i16,
15 16 pub created_at: DateTime<Utc>,
16 17 /// "sale" or "tip"
@@ -5,6 +5,7 @@ use serde::Serialize;
5 5 use sqlx::FromRow;
6 6
7 7 use super::super::id_types::*;
8 + use super::super::validated_types::Cents;
8 9
9 10 /// A one-time tip from a fan to a creator.
10 11 #[derive(Debug, Clone, FromRow, Serialize)]
@@ -13,7 +14,7 @@ pub struct DbTip {
13 14 pub tipper_id: UserId,
14 15 pub recipient_id: UserId,
15 16 pub project_id: Option<ProjectId>,
16 - pub amount_cents: i32,
17 + pub amount_cents: Cents,
17 18 pub message: Option<String>,
18 19 pub status: super::super::TransactionStatus,
19 20 pub stripe_payment_intent_id: Option<String>,
@@ -42,7 +43,7 @@ pub struct DbRevenueSplit {
42 43 pub tip_id: Option<TipId>,
43 44 pub transaction_id: Option<TransactionId>,
44 45 pub recipient_id: UserId,
45 - pub amount_cents: i32,
46 + pub amount_cents: Cents,
46 47 pub split_percent: i16,
47 48 pub stripe_transfer_id: Option<String>,
48 49 pub status: super::super::TransactionStatus,
@@ -57,7 +58,7 @@ pub struct DbTipWithUser {
57 58 pub tipper_id: UserId,
58 59 pub recipient_id: UserId,
59 60 pub project_id: Option<ProjectId>,
60 - pub amount_cents: i32,
61 + pub amount_cents: Cents,
61 62 pub message: Option<String>,
62 63 pub status: super::super::TransactionStatus,
63 64 pub created_at: DateTime<Utc>,