Skip to main content

max / makenotwork

Nitpick Run 1: dedup helpers, fix scheduler race, validate emails - routes/api/mod.rs: merge ensure_project_owner/verify_project_ownership, alphabetize module list, consolidate use blocks, import options(), split combined route-method chains, fix doc-comment double-space, move GET /api/items/{id}/keys from write_routes to read_routes - scheduler/cleanup.rs: hoist get_project_ids_for_user and get_sync_apps_by_creator to a single call each in cleanup_user_s3_and_delete (closes race window between enqueue and delete), pass UserId by value to cleanup_git_repos_on_disk, drop redundant Vec<&str> in purge_expired_deleted_items, fix dead-letter log threshold off-by-one (>10 -> >=10, >5 -> >=5), broaden module doc - scheduler/cleanup.rs: cleanup_sandbox_accounts, cleanup_stale_pending_transactions, retry_pending_s3_deletions now always record_job_run (new job names sandbox_cleanup, stale_pending_cleanup) so PoM can distinguish "ran with zero work" from "didn't run" - git_ssh.rs: parse_repo_path returns symmetric (&str, &str) with differentiated bail messages, drop stale collapsible_if allow, fix capitalization in unknown-command bail, extract display_with_ellipsis helper so cmd_ssh_key_list truncates labels like cmd_ssh_repo_list truncates descriptions - constants.rs: add GUEST_CHECKOUT_RATE_LIMIT_{PER_SEC,BURST} to replace the magic 1, 10 in routes/api/mod.rs - validation/users.rs: add normalize_email helper backed by email_address crate, used at email_signup (landing notify-me) and claim_free_guest entry points to replace two separate ad-hoc validators Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-13 18:17 UTC
Commit: d8c80675a8eae8b4fb902aa2301447dcf6a3bb5c
Parent: 14037c3
9 files changed, +197 insertions, -127 deletions
@@ -2003,6 +2003,15 @@ dependencies = [
2003 2003 ]
2004 2004
2005 2005 [[package]]
2006 + name = "email_address"
2007 + version = "0.2.9"
2008 + source = "registry+https://github.com/rust-lang/crates.io-index"
2009 + checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
2010 + dependencies = [
2011 + "serde",
2012 + ]
2013 +
2014 + [[package]]
2006 2015 name = "embedded-io"
2007 2016 version = "0.4.0"
2008 2017 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3481,6 +3490,7 @@ dependencies = [
3481 3490 "dashmap",
3482 3491 "docengine",
3483 3492 "dotenvy",
3493 + "email_address",
3484 3494 "git2",
3485 3495 "goblin",
3486 3496 "governor",
@@ -93,6 +93,9 @@ log = "0.4"
93 93 thiserror = "2.0.18"
94 94 anyhow = "1.0.102"
95 95
96 + # Email validation (used at notify-me signup and guest-checkout entry points)
97 + email_address = "0.2"
98 +
96 99 # Metrics
97 100 metrics = "0.24"
98 101 metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
@@ -20,6 +20,22 @@ Priority order. See `human_todo.md` for the full manual testing feature map.
20 20
21 21 ---
22 22
23 + ## Stripe SDK Migration (Post-Launch, was blocking)
24 +
25 + - [ ] **Migrate off `async-stripe = "0.37.3"`**. The 0.x line is frozen on Stripe API pre-2025-03-31; `Subscription.current_period_end` and invoice line item `proration` moved/renamed in newer Stripe API versions and the 0.x structs fail to deserialize current webhook payloads with `BadParse(missing field)`.
26 + - **Mitigation in place (2026-05-13):** Stripe Dashboard API version pinned to a pre-2025-03-31 version so payloads match the old shape. Replayed 20 missed events from dashboard after pinning.
27 + - **Proper fix:** evaluate `async-stripe 1.0` (RC as of 2026-05; crate-split into `stripe_core` / `stripe_billing` / `stripe_connect` / `stripe_checkout` / `stripe_misc`, codegen overhauled, ~40 call sites need rewriting) vs `stripe-rust` fork vs hand-rolled JSON parse for webhooks only.
28 + - **Affected files:** `payments/{mod,checkout,connect,webhooks}.rs`, `routes/stripe/**`, `scheduler/webhooks.rs`.
29 + - **Why it can't wait forever:** the dashboard pin blocks adopting any new Stripe feature tied to API version (e.g. newer Connect onboarding flows, tax features). Roll forward before the pin gets stale or Stripe deprecates the version.
30 +
31 + ---
32 +
33 + ## Custom Pages (Post-Launch)
34 +
35 + - [ ] **MySpace-style custom pages**: user-editable HTML + CSS (no JS, no external resources) for user/project/item pages. Subdomain-isolated (`u.makenot.work`), `ammonia` + `lightningcss` sanitization, on-platform-only URLs. Full plan: `plans/custom-pages.md`.
36 +
37 + ---
38 +
23 39 ## Upload Improvements (Post-Launch)
24 40
25 41 - [ ] **Background uploads**: allow navigating away from the Files tab during upload. Track upload state server-side (pending_uploads table exists). Show upload status in a persistent UI element (toast or header badge) so video/large-file creators aren't stuck on the page.
@@ -55,6 +71,42 @@ Note: "Appeals reviewed by same person" and "liability cap" are known one-person
55 71
56 72 ---
57 73
74 + ## Nitpick Run 1 (2026-05-13)
75 +
76 + Scope: `routes/api/mod.rs`, `scheduler/cleanup.rs`, `git_ssh.rs`. [FACT] items only — preference items dropped.
77 +
78 + ### routes/api/mod.rs
79 + - [x] Dedup `ensure_project_owner` / `verify_project_ownership` — merged into one
80 + - [x] Import `axum::routing::options` instead of fully qualifying
81 + - [x] Reorder module list alphabetically
82 + - [x] Co-locate the three `use` blocks
83 + - [x] Replace magic `rate_limiter_per_sec(1, 10)` with new `GUEST_CHECKOUT_RATE_LIMIT_*` constants
84 + - [x] Fix double-space in `json_error_layer` doc-comment
85 + - [x] Unified route-method style on split (combined form converted at repo-collaborators and ssh-keys)
86 + - [x] Moved `license_keys::list_keys` GET from `write_routes` to `read_routes` — was being limited as a mutation (burst 30, 2/sec) instead of as a read (burst 60, 10/sec)
87 +
88 + ### scheduler/cleanup.rs
89 + - [x] Hoist `get_project_ids_for_user` / `get_sync_apps_by_creator` to one call each — fixes race window between enqueue and delete
90 + - [x] `tracing::info!(event = event, ...)` → shorthand `event,`
91 + - [x] `cleanup_git_repos_on_disk` now takes `UserId` by value
92 + - [x] Dropped redundant `item_keys: Vec<&str>` in `purge_expired_deleted_items`
93 + - [x] Off-by-one in dead-letter / stuck log messages — guards now `>= 10` / `>= 5`
94 + - [x] Module doc lists all jobs
95 + - [x] Normalize scheduler-job empty-run convention — `cleanup_sandbox_accounts`, `cleanup_stale_pending_transactions`, `retry_pending_s3_deletions` now always `record_job_run`; new job names `sandbox_cleanup`, `stale_pending_cleanup`
96 +
97 + ### git_ssh.rs
98 + - [x] `parse_repo_path` returns symmetric `(&str, &str)`; differentiated bail messages at the three sites
99 + - [x] Removed `#[allow(clippy::collapsible_if, clippy::collapsible_else_if)]` — no longer needed
100 + - [x] "unknown command. Available: ..." → "unknown command; available: ..."
101 + - [ ] Move `use std::os::unix::process::CommandExt;` out of `exec_git_shell` — reverted as unsafe (cfg gymnastics for non-unix build; preference-level anyway)
102 + - [x] Truncate labels in `cmd_ssh_key_list` consistently with descriptions in `cmd_ssh_repo_list` — extracted `display_with_ellipsis` helper
103 +
104 + ### Not-a-nit follow-ups
105 + - [x] Race in `cleanup_user_s3_and_delete` — fixed in the same change as the DB-query hoist; enqueue and delete share one snapshot
106 + - [x] Email validation consolidated — added `email_address = "0.2"` (no new transitive deps), new `validation::normalize_email` used at both `email_signup` and `guest_checkout` entry points
107 +
108 + ---
109 +
58 110 ## Code Fuzz / Ultra Fuzz — Accepted Risks & Deferred
59 111
60 112 Remaining open items from Runs 21-24 and Code Fuzz (2026-05-08). All SERIOUS items resolved through Run 23. Run 24 SERIOUS items above.
@@ -101,6 +153,14 @@ Remaining open items from Runs 21-24 and Code Fuzz (2026-05-08). All SERIOUS ite
101 153 - [ ] Add cross-project item view
102 154 - [ ] Git browser: add discover/follow integration
103 155
156 + ### OAuth: `perks` object on `/oauth/userinfo`
157 + Generic mechanism so any "Log in with MNW" implementer (MT first, future services next) can read user entitlements without bespoke endpoints. Pull-on-demand only; no webhook push yet.
158 + - [x] Extend `UserinfoResponse` in `src/routes/oauth.rs` with a `perks` object (`fan_plus`, `is_creator`, structured `creator_tier { tier, features }`)
159 + - [x] Populate from DB at userinfo-time (always fresh)
160 + - [x] `CreatorTier::features()` capability strings (`file_uploads`, `large_files`)
161 + - [x] Workflow tests: default user, creator tier, fan_plus, unauthorized
162 + - [x] `docs/oauth_integration.md` — flow, perks contract, refresh ergonomics, stability rules
163 +
104 164 ### Global UX
105 165 - [ ] Find a better place for the keyboard shortcuts help button (removed from header nav)
106 166 - [ ] Add toast stacking for multiple notifications
@@ -98,6 +98,9 @@ pub const API_READ_RATE_LIMIT_BURST: u32 = 60;
98 98 // API export endpoints: burst 3, then 1/sec
99 99 pub const API_EXPORT_RATE_LIMIT_PER_SEC: u64 = 1;
100 100 pub const API_EXPORT_RATE_LIMIT_BURST: u32 = 3;
101 + // Guest checkout (public, no auth): burst 10, then 1/sec
102 + pub const GUEST_CHECKOUT_RATE_LIMIT_PER_SEC: u64 = 1;
103 + pub const GUEST_CHECKOUT_RATE_LIMIT_BURST: u32 = 10;
101 104 // License key validation (public): burst 20, then 5/sec
102 105 pub const LICENSE_KEY_RATE_LIMIT_MS: u64 = 200;
103 106 pub const LICENSE_KEY_RATE_LIMIT_BURST: u32 = 20;
@@ -80,7 +80,7 @@ async fn exec_git_operation(
80 80 .await?
81 81 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
82 82
83 - let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, &repo_name).await? {
83 + let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, repo_name).await? {
84 84 Some(repo) => repo,
85 85 None => {
86 86 // Auto-create on push if the authenticated user owns the namespace.
@@ -90,7 +90,7 @@ async fn exec_git_operation(
90 90 }
91 91
92 92 tracing::info!(owner = %owner, repo = %repo_name, "registering new repository");
93 - db::git_repos::create_repo(pool, owner_user.id, &repo_name).await?
93 + db::git_repos::create_repo(pool, owner_user.id, repo_name).await?
94 94 }
95 95 };
96 96
@@ -143,26 +143,20 @@ fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
143 143 Ok((operation, repo_path.to_string()))
144 144 }
145 145
146 - fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> {
146 + fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> {
147 147 let path = path.trim_start_matches('/');
148 - let parts: Vec<&str> = path.splitn(2, '/').collect();
149 - if parts.len() != 2 {
150 - anyhow::bail!("invalid repository path");
151 - }
152 -
153 - let owner = parts[0];
154 - let mut repo_name = parts[1].to_string();
148 + let (owner, rest) = path
149 + .split_once('/')
150 + .ok_or_else(|| anyhow::anyhow!("invalid repository path: missing owner or repo"))?;
155 151
156 - if owner.contains("..") || repo_name.contains("..") {
157 - anyhow::bail!("invalid repository path");
152 + if owner.contains("..") || rest.contains("..") {
153 + anyhow::bail!("invalid repository path: path traversal not allowed");
158 154 }
159 155
160 - if repo_name.ends_with(".git") {
161 - repo_name.truncate(repo_name.len() - 4);
162 - }
156 + let repo_name = rest.strip_suffix(".git").unwrap_or(rest);
163 157
164 158 if owner.is_empty() || repo_name.is_empty() {
165 - anyhow::bail!("invalid repository path");
159 + anyhow::bail!("invalid repository path: empty owner or repo name");
166 160 }
167 161
168 162 Ok((owner, repo_name))
@@ -209,7 +203,6 @@ enum ManagementCommand {
209 203 }
210 204
211 205 /// Split a command string on whitespace, respecting double-quoted segments.
212 - #[allow(clippy::collapsible_if, clippy::collapsible_else_if)]
213 206 fn shell_tokenize(input: &str) -> Vec<String> {
214 207 let mut tokens = Vec::new();
215 208 let mut current = String::new();
@@ -262,7 +255,7 @@ fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementComma
262 255 }),
263 256 ["key", "list"] => Ok(ManagementCommand::KeyList),
264 257 ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { fingerprint: fingerprint.to_string() }),
265 - _ => anyhow::bail!("unknown command. Available: repo list|info|delete|set-visibility|set-description, key list|rm"),
258 + _ => anyhow::bail!("unknown command; available: repo list|info|delete|set-visibility|set-description, key list|rm"),
266 259 }
267 260 }
268 261
@@ -290,6 +283,19 @@ async fn exec_management_command(
290 283 }
291 284 }
292 285
286 + /// Render a value for a fixed-width table column: "-" if empty, ellipsized if
287 + /// wider than `max_width` (chars), otherwise the value unchanged.
288 + fn display_with_ellipsis(value: &str, max_width: usize) -> String {
289 + if value.is_empty() {
290 + "-".to_string()
291 + } else if value.chars().count() > max_width {
292 + let truncated: String = value.chars().take(max_width.saturating_sub(3)).collect();
293 + format!("{truncated}...")
294 + } else {
295 + value.to_string()
296 + }
297 + }
298 +
293 299 async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
294 300 let repos = db::git_repos::get_repos_by_user(pool, user_id).await?;
295 301
@@ -302,14 +308,7 @@ async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()>
302 308 println!("{}", "-".repeat(70));
303 309
304 310 for repo in &repos {
305 - let desc = if repo.description.is_empty() {
306 - "-".to_string()
307 - } else if repo.description.chars().count() > 28 {
308 - let truncated: String = repo.description.chars().take(25).collect();
309 - format!("{truncated}...")
310 - } else {
311 - repo.description.clone()
312 - };
311 + let desc = display_with_ellipsis(&repo.description, 28);
313 312 println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc);
314 313 }
315 314
@@ -409,7 +408,7 @@ async fn cmd_ssh_key_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()>
409 408 println!("{}", "-".repeat(80));
410 409
411 410 for key in &keys {
412 - let label = if key.label.is_empty() { "-" } else { &key.label };
411 + let label = display_with_ellipsis(&key.label, 20);
413 412 println!(
414 413 "{:<50} {:<20} {}",
415 414 key.fingerprint,
@@ -321,20 +321,8 @@ pub(super) async fn claim_free_guest(
321 321 Path(item_id): Path<ItemId>,
322 322 Json(body): Json<FreeGuestClaimRequest>,
323 323 ) -> Result<Response> {
324 - // Email validation: must have exactly one @, local + domain parts, reasonable length
325 - let email = body.email.trim().to_string();
326 - let valid = match email.split_once('@') {
327 - Some((local, domain)) => {
328 - !local.is_empty()
329 - && domain.contains('.')
330 - && domain.len() >= 3
331 - && email.chars().count() <= 254
332 - }
333 - None => false,
334 - };
335 - if !valid {
336 - return Err(AppError::BadRequest("Invalid email address".to_string()));
337 - }
324 + let email = crate::validation::normalize_email(&body.email)
325 + .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?;
338 326
339 327 let item = db::items::get_item_by_id(&state.db, item_id)
340 328 .await?
@@ -17,44 +17,43 @@
17 17 //! producing bounded result sets (typically <100 items).
18 18
19 19 mod blog;
20 + mod cart;
20 21 mod categories;
22 + mod collections;
21 23 mod content_insertions;
22 - mod promo_codes;
24 + mod domains;
23 25 mod exports;
24 26 mod follows;
27 + mod guest_checkout;
28 + mod imports;
29 + mod internal;
25 30 mod items;
26 31 pub(crate) mod license_keys;
27 32 mod links;
28 33 mod passkeys;
29 - mod projects;
30 34 mod project_sections;
35 + mod projects;
36 + mod promo_codes;
37 + mod reports;
38 + pub(crate) mod ssh_keys;
31 39 mod subscriptions;
32 40 mod tags;
33 41 pub(crate) mod totp;
34 42 mod users;
35 - pub(crate) mod ssh_keys;
36 - mod reports;
37 - mod collections;
38 43 mod validate;
39 44 mod wishlists;
40 - mod cart;
41 - mod domains;
42 - mod guest_checkout;
43 - mod imports;
44 - mod internal;
45 45
46 46 use axum::{
47 47 extract::{Request, State},
48 48 middleware::Next,
49 49 response::{IntoResponse, Response},
50 - routing::{delete, get, post, put},
50 + routing::{delete, get, options, post, put},
51 51 Json, Router,
52 52 };
53 + use serde::{Deserialize, Serialize};
53 54 use serde_json::json;
54 55 use tower_governor::GovernorLayer;
55 56
56 - use serde::{Deserialize, Serialize};
57 -
58 57 use crate::{
59 58 constants,
60 59 db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId},
@@ -64,7 +63,7 @@ use crate::{
64 63
65 64 /// Fetch a project and verify the user owns it. Shared by all ownership checks
66 65 /// that go through a project (items, blog posts, direct project access).
67 - async fn ensure_project_owner(
66 + pub(super) async fn verify_project_ownership(
68 67 state: &AppState,
69 68 project_id: ProjectId,
70 69 user_id: UserId,
@@ -78,14 +77,6 @@ async fn ensure_project_owner(
78 77 Ok(project)
79 78 }
80 79
81 - pub(super) async fn verify_project_ownership(
82 - state: &AppState,
83 - project_id: ProjectId,
84 - user_id: UserId,
85 - ) -> Result<db::DbProject> {
86 - ensure_project_owner(state, project_id, user_id).await
87 - }
88 -
89 80 pub(super) async fn verify_item_ownership(
90 81 state: &AppState,
91 82 item_id: ItemId,
@@ -94,7 +85,7 @@ pub(super) async fn verify_item_ownership(
94 85 let item = db::items::get_item_by_id(&state.db, item_id)
95 86 .await?
96 87 .ok_or(AppError::NotFound)?;
97 - let project = ensure_project_owner(state, item.project_id, user_id).await?;
88 + let project = verify_project_ownership(state, item.project_id, user_id).await?;
98 89 Ok((item, project))
99 90 }
100 91
@@ -106,16 +97,16 @@ pub(super) async fn verify_blog_post_ownership(
106 97 let post = db::blog_posts::get_blog_post_by_id(&state.db, blog_post_id)
107 98 .await?
108 99 .ok_or(AppError::NotFound)?;
109 - ensure_project_owner(state, post.project_id, user_id).await?;
100 + verify_project_ownership(state, post.project_id, user_id).await?;
110 101 Ok(post)
111 102 }
112 103
113 104 /// Middleware that converts HTML error responses into JSON on API routes.
114 105 ///
115 106 /// When `AppError::into_response()` fires it stashes an [`ApiErrorMessage`] in
116 - /// the response extensions. This layer checks for that extension and, if
107 + /// the response extensions. This layer checks for that extension and, if
117 108 /// present, replaces the HTML body with `{"error": "…"}` while preserving the
118 - /// original status code. Page routes never hit this layer, so they keep
109 + /// original status code. Page routes never hit this layer, so they keep
119 110 /// getting the full HTML error template.
120 111 async fn json_error_layer(req: Request, next: Next) -> Response {
121 112 let response = next.run(req).await;
@@ -169,16 +160,7 @@ async fn email_signup(
169 160 State(state): State<AppState>,
170 161 Json(form): Json<EmailSignupForm>,
171 162 ) -> Result<impl IntoResponse> {
172 - let email = form.email.trim().to_lowercase();
173 - // Basic format check: must have @ with a . after it
174 - if let Some(at_pos) = email.find('@') {
175 - if !email[at_pos + 1..].contains('.') {
176 - return Err(AppError::Validation("Please enter a valid email address".into()));
177 - }
178 - } else {
179 - return Err(AppError::Validation("Please enter a valid email address".into()));
180 - }
181 -
163 + let email = crate::validation::normalize_email(&form.email)?;
182 164 db::email_signups::insert_email_signup(&state.db, &email, "landing").await?;
183 165 Ok(Json(json!({"success": true})))
184 166 }
@@ -215,7 +197,8 @@ pub fn api_routes() -> Router<AppState> {
215 197 // Git repo management
216 198 .route("/api/repos", post(projects::create_repo))
217 199 .route("/api/repos/{id}/visibility", put(projects::update_repo_visibility))
218 - .route("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators).post(projects::add_repo_collaborator))
200 + .route("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators))
201 + .route("/api/repos/{id}/collaborators", post(projects::add_repo_collaborator))
219 202 .route("/api/repos/{repo_id}/collaborators/{user_id}", delete(projects::remove_repo_collaborator))
220 203 .route("/api/projects/{id}/repos", post(projects::link_repo))
221 204 .route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo))
@@ -293,7 +276,6 @@ pub fn api_routes() -> Router<AppState> {
293 276 // License key management (creator)
294 277 .route("/api/items/{id}/license-settings", put(license_keys::update_license_settings))
295 278 .route("/api/items/{id}/keys", post(license_keys::generate_key))
296 - .route("/api/items/{id}/keys", get(license_keys::list_keys))
297 279 .route("/api/keys/{id}/revoke", post(license_keys::revoke_key))
298 280 // Promo code management (creator)
299 281 .route("/api/promo-codes", post(promo_codes::create_promo_code))
@@ -331,7 +313,8 @@ pub fn api_routes() -> Router<AppState> {
331 313 .route("/api/items/{id}/insertions", post(content_insertions::create_placement))
332 314 .route("/api/item-insertions/{id}", delete(content_insertions::delete_placement))
333 315 // SSH key management
334 - .route("/api/users/me/ssh-keys", get(ssh_keys::list_keys).post(ssh_keys::add_key))
316 + .route("/api/users/me/ssh-keys", get(ssh_keys::list_keys))
317 + .route("/api/users/me/ssh-keys", post(ssh_keys::add_key))
335 318 .route("/api/users/me/ssh-keys/{id}", delete(ssh_keys::delete_key))
336 319 // Reports
337 320 .route("/api/reports", post(reports::submit_report))
@@ -399,6 +382,7 @@ pub fn api_routes() -> Router<AppState> {
399 382 .route("/api/items/{id}/versions", get(items::list_versions))
400 383 .route("/api/items/{id}/chapters", get(items::list_chapters))
401 384 .route("/api/items/{id}/sections", get(items::list_sections))
385 + .route("/api/items/{id}/keys", get(license_keys::list_keys))
402 386 .route("/api/projects/{id}/sections", get(project_sections::list_sections))
403 387 .route("/api/projects/{id}/blog", get(blog::list_blog_posts))
404 388 .route("/api/blog/{id}", get(blog::get_blog_post))
@@ -454,10 +438,13 @@ pub fn api_routes() -> Router<AppState> {
454 438 });
455 439
456 440 // Guest checkout routes — public, no auth, CORS-enabled, stricter rate limit
457 - let guest_checkout_rate_limit = crate::helpers::rate_limiter_per_sec(1, 10);
441 + let guest_checkout_rate_limit = crate::helpers::rate_limiter_per_sec(
442 + constants::GUEST_CHECKOUT_RATE_LIMIT_PER_SEC,
443 + constants::GUEST_CHECKOUT_RATE_LIMIT_BURST,
444 + );
458 445 let guest_routes = Router::new()
459 446 .route("/api/checkout/guest/{item_id}", post(guest_checkout::create_guest_checkout))
460 - .route("/api/checkout/guest/{item_id}", axum::routing::options(guest_checkout::guest_checkout_preflight))
447 + .route("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight))
461 448 .route("/api/checkout/guest-free/{item_id}", post(guest_checkout::claim_free_guest))
462 449 .route("/api/purchases/claim", post(guest_checkout::claim_purchase))
463 450 .route_layer(GovernorLayer {
@@ -1,4 +1,6 @@
1 - //! Account cleanup: sandbox expiry, terminated accounts, content removal, IP scrubbing.
1 + //! Scheduled cleanup jobs: sandbox expiry, terminated accounts, content
2 + //! removal, IP scrubbing, stale pending transactions, orphaned uploads, cart
3 + //! items, soft-deleted item purges, and pending S3 deletion retries.
2 4
3 5 use crate::db;
4 6 use crate::AppState;
@@ -6,7 +8,6 @@ use crate::AppState;
6 8 /// Delete expired sandbox accounts and their S3 objects.
7 9 pub(super) async fn cleanup_sandbox_accounts(state: &AppState) {
8 10 let expired_ids = match db::users::get_expired_sandbox_ids(&state.db).await {
9 - Ok(ids) if ids.is_empty() => return,
10 11 Ok(ids) => ids,
11 12 Err(e) => {
12 13 tracing::error!(error = ?e, "failed to query expired sandbox accounts");
@@ -14,9 +15,13 @@ pub(super) async fn cleanup_sandbox_accounts(state: &AppState) {
14 15 }
15 16 };
16 17
18 + let mut deleted = 0i64;
17 19 for user_id in &expired_ids {
18 - cleanup_user_s3_and_delete(state, *user_id, "sandbox_expired", "sandbox").await;
20 + if cleanup_user_s3_and_delete(state, *user_id, "sandbox_expired", "sandbox").await {
21 + deleted += 1;
22 + }
19 23 }
24 + let _ = db::scheduler_jobs::record_job_run(&state.db, "sandbox_cleanup", deleted).await;
20 25 }
21 26
22 27 /// Clean up a user's S3 objects, git repos, and CASCADE-delete the user row.
@@ -24,24 +29,25 @@ pub(super) async fn cleanup_sandbox_accounts(state: &AppState) {
24 29 /// Shared between sandbox, terminated, and content-removal account cleanup.
25 30 /// S3 objects are deleted first (before CASCADE removes the DB rows that reference them).
26 31 async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event: &str, label: &str) -> bool {
27 - // Collect all S3 prefixes to enqueue as a durable safety net
28 - let mut keys: Vec<(String, String)> = Vec::new();
32 + // Resolve everything we need from the DB once — the enqueue list and the
33 + // delete list must come from the same snapshot, or a project/app created
34 + // between calls will be enqueued but not deleted (or vice versa).
35 + let project_ids = db::projects::get_project_ids_for_user(&state.db, user_id)
36 + .await
37 + .unwrap_or_default();
38 + let sync_apps = db::synckit::get_sync_apps_by_creator(&state.db, user_id)
39 + .await
40 + .unwrap_or_default();
29 41
30 - // Main bucket prefixes
31 42 let user_prefix = format!("{user_id}/");
43 + let mut keys: Vec<(String, String)> = Vec::new();
32 44 keys.push((user_prefix.clone(), "main".to_string()));
33 - if let Ok(project_ids) = db::projects::get_project_ids_for_user(&state.db, user_id).await {
34 - for pid in &project_ids {
35 - keys.push((format!("projects/{pid}/"), "main".to_string()));
36 - }
45 + for pid in &project_ids {
46 + keys.push((format!("projects/{pid}/"), "main".to_string()));
37 47 }
38 -
39 - // SyncKit bucket prefixes
40 - if let Ok(apps) = db::synckit::get_sync_apps_by_creator(&state.db, user_id).await {
41 - for app in &apps {
42 - keys.push((format!("{}/", app.id), "synckit".to_string()));
43 - keys.push((format!("ota/{}/", app.id), "synckit".to_string()));
44 - }
48 + for app in &sync_apps {
49 + keys.push((format!("{}/", app.id), "synckit".to_string()));
50 + keys.push((format!("ota/{}/", app.id), "synckit".to_string()));
45 51 }
46 52
47 53 // Enqueue all keys before any destructive work
@@ -50,26 +56,20 @@ async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event
50 56 return false;
51 57 }
52 58
53 - // Best-effort S3 deletes (existing logic)
54 59 if let Some(ref s3) = state.s3 {
55 60 if let Err(e) = s3.delete_prefix(&user_prefix).await {
56 61 tracing::warn!(error = ?e, %user_id, "{label}: failed to delete user S3 objects");
57 62 }
58 -
59 - if let Ok(project_ids) = db::projects::get_project_ids_for_user(&state.db, user_id).await {
60 - for pid in project_ids {
61 - let proj_prefix = format!("projects/{pid}/");
62 - if let Err(e) = s3.delete_prefix(&proj_prefix).await {
63 - tracing::warn!(error = ?e, %user_id, %pid, "{label}: failed to delete project S3 objects");
64 - }
63 + for pid in &project_ids {
64 + let proj_prefix = format!("projects/{pid}/");
65 + if let Err(e) = s3.delete_prefix(&proj_prefix).await {
66 + tracing::warn!(error = ?e, %user_id, %pid, "{label}: failed to delete project S3 objects");
65 67 }
66 68 }
67 69 }
68 70
69 - if let Some(ref synckit_s3) = state.synckit_s3
70 - && let Ok(apps) = db::synckit::get_sync_apps_by_creator(&state.db, user_id).await
71 - {
72 - for app in &apps {
71 + if let Some(ref synckit_s3) = state.synckit_s3 {
72 + for app in &sync_apps {
73 73 let blob_prefix = format!("{}/", app.id);
74 74 if let Err(e) = synckit_s3.delete_prefix(&blob_prefix).await {
75 75 tracing::warn!(error = ?e, %user_id, app_id = %app.id, "{label}: failed to delete SyncKit blobs");
@@ -85,7 +85,7 @@ async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event
85 85 if let Some(ref git_root) = state.config.git_repos_path
86 86 && let Ok(Some(user)) = db::users::get_user_by_id(&state.db, user_id).await
87 87 {
88 - cleanup_git_repos_on_disk(git_root, &user.username, &user_id).await;
88 + cleanup_git_repos_on_disk(git_root, &user.username, user_id).await;
89 89 }
90 90
91 91 // CASCADE delete user row
@@ -93,7 +93,7 @@ async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event
93 93 tracing::error!(error = ?e, %user_id, "{label}: failed to delete account");
94 94 false
95 95 } else {
96 - tracing::info!(%user_id, event = event, "{label}: account cleaned up");
96 + tracing::info!(%user_id, event, "{label}: account cleaned up");
97 97 true
98 98 }
99 99 }
@@ -103,9 +103,8 @@ async fn cleanup_user_s3_and_delete(state: &AppState, user_id: db::UserId, event
103 103 /// Must be called before `delete_user` (which CASCADE-deletes the git_repos rows).
104 104 /// Best-effort: logs warnings on failure but does not block account deletion.
105 105 /// Runs blocking I/O on a dedicated thread to avoid stalling the Tokio runtime.
106 - pub(super) async fn cleanup_git_repos_on_disk(git_repos_path: &str, username: &str, user_id: &db::UserId) {
106 + pub(super) async fn cleanup_git_repos_on_disk(git_repos_path: &str, username: &str, user_id: db::UserId) {
107 107 let user_git_dir = std::path::Path::new(git_repos_path).join(username);
108 - let user_id = *user_id;
109 108 if user_git_dir.exists() {
110 109 let path = user_git_dir.clone();
111 110 match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&path)).await {
@@ -170,7 +169,6 @@ pub(super) async fn cleanup_stale_pending_transactions(state: &AppState) {
170 169 )
171 170 .await
172 171 {
173 - Ok(ids) if ids.is_empty() => return,
174 172 Ok(ids) => ids,
175 173 Err(e) => {
176 174 tracing::error!(error = ?e, "failed to clean up stale pending transactions");
@@ -178,7 +176,7 @@ pub(super) async fn cleanup_stale_pending_transactions(state: &AppState) {
178 176 }
179 177 };
180 178
181 - let mut released = 0u64;
179 + let mut released = 0i64;
182 180 for pc_id in promo_ids.into_iter().flatten() {
183 181 if let Err(e) = db::promo_codes::release_use_count(&state.db, pc_id).await {
184 182 tracing::warn!(promo_code_id = %pc_id, error = ?e, "failed to release promo code use count");
@@ -190,6 +188,7 @@ pub(super) async fn cleanup_stale_pending_transactions(state: &AppState) {
190 188 if released > 0 {
191 189 tracing::info!(released, "released promo code reservations from stale pending transactions");
192 190 }
191 + let _ = db::scheduler_jobs::record_job_run(&state.db, "stale_pending_cleanup", released).await;
193 192 }
194 193
195 194 /// NULL out IP addresses older than 30 days in user_sessions.
@@ -250,16 +249,14 @@ pub(super) async fn purge_expired_deleted_items(state: &AppState) {
250 249 return;
251 250 }
252 251
253 - // Best-effort S3 deletes (existing logic)
254 252 if let Some(ref s3) = state.s3 {
255 - let item_keys: Vec<&str> = all_s3_keys.iter().map(|(k, _)| k.as_str()).collect();
256 - for key in &item_keys {
253 + for (key, _bucket) in &all_s3_keys {
257 254 if let Err(e) = s3.delete_object(key).await {
258 255 tracing::warn!(key = %key, error = ?e, "failed to delete S3 object for purged item");
259 256 }
260 257 }
261 - if !item_keys.is_empty() {
262 - tracing::info!(count = item_keys.len(), "deleted S3 objects for purged items");
258 + if !all_s3_keys.is_empty() {
259 + tracing::info!(count = all_s3_keys.len(), "deleted S3 objects for purged items");
263 260 }
264 261 }
265 262
@@ -372,7 +369,6 @@ pub(super) async fn retry_pending_s3_deletions(state: &AppState) {
372 369 chrono::Duration::minutes(10),
373 370 100,
374 371 ).await {
375 - Ok(rows) if rows.is_empty() => return,
376 372 Ok(rows) => rows,
377 373 Err(e) => {
378 374 tracing::error!(error = ?e, "failed to fetch stale pending S3 deletions");
@@ -380,14 +376,19 @@ pub(super) async fn retry_pending_s3_deletions(state: &AppState) {
380 376 }
381 377 };
382 378
379 + if stale.is_empty() {
380 + let _ = db::scheduler_jobs::record_job_run(&state.db, "s3_deletion_retry", 0).await;
381 + return;
382 + }
383 +
383 384 let mut completed_ids = Vec::new();
384 385 for row in &stale {
385 - if row.attempts > 10 {
386 + if row.attempts >= 10 {
386 387 tracing::error!(s3_key = %row.s3_key, bucket = %row.bucket, source = %row.source, attempts = row.attempts,
387 388 "S3 deletion dead-lettered after 10 attempts — removing from queue");
388 389 completed_ids.push(row.id);
389 390 continue;
390 - } else if row.attempts > 5 {
391 + } else if row.attempts >= 5 {
391 392 tracing::warn!(s3_key = %row.s3_key, bucket = %row.bucket, source = %row.source, attempts = row.attempts,
392 393 "S3 deletion stuck after 5+ attempts");
393 394 }
@@ -439,7 +440,7 @@ mod tests {
439 440 std::fs::write(user_dir.join("repo.git/HEAD"), "ref: refs/heads/main\n").unwrap();
440 441
441 442 let user_id = db::UserId::nil();
442 - cleanup_git_repos_on_disk(git_root.to_str().unwrap(), "testuser", &user_id).await;
443 + cleanup_git_repos_on_disk(git_root.to_str().unwrap(), "testuser", user_id).await;
443 444
444 445 assert!(!user_dir.exists(), "user git directory should be deleted");
445 446 }
@@ -448,6 +449,6 @@ mod tests {
448 449 async fn cleanup_git_repos_noop_if_missing() {
449 450 let tmp = tempfile::tempdir().unwrap();
450 451 let user_id = db::UserId::nil();
451 - cleanup_git_repos_on_disk(tmp.path().to_str().unwrap(), "nonexistent", &user_id).await;
452 + cleanup_git_repos_on_disk(tmp.path().to_str().unwrap(), "nonexistent", user_id).await;
452 453 }
453 454 }
@@ -3,6 +3,25 @@
3 3 use crate::error::AppError;
4 4 use super::limits;
5 5
6 + /// RFC 5321 total length cap. The grammar allows more in places, but addresses
7 + /// longer than this won't survive any real mail transport.
8 + const EMAIL_MAX_LEN: usize = 254;
9 +
10 + /// Normalize and validate an email address.
11 + ///
12 + /// Returns the trimmed-lowercased form on success. Rejects RFC-invalid syntax
13 + /// and addresses longer than 254 characters. Used at public entry points
14 + /// (notify-me signup, guest checkout) where we have no follow-up verification.
15 + pub fn normalize_email(input: &str) -> Result<String, AppError> {
16 + let email = input.trim().to_lowercase();
17 + if email.len() > EMAIL_MAX_LEN || !email_address::EmailAddress::is_valid(&email) {
18 + return Err(AppError::Validation(
19 + "Please enter a valid email address".into(),
20 + ));
21 + }
22 + Ok(email)
23 + }
24 +
6 25 /// Validate a display name
7 26 pub fn validate_display_name(name: &str) -> Result<(), AppError> {
8 27 if name.chars().count() > limits::DISPLAY_NAME_MAX {