max / makenotwork
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 { |