max / makenotwork
26 files changed,
+483 insertions,
-101 deletions
| @@ -448,10 +448,8 @@ async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> { | |||
| 448 | 448 | let (revenue_cents, completed, refunded) = | |
| 449 | 449 | db::transactions::get_platform_revenue_stats(pool).await?; | |
| 450 | 450 | ||
| 451 | - | let dollars = revenue_cents as f64 / 100.0; | |
| 452 | - | ||
| 453 | 451 | println!("Platform Revenue"); | |
| 454 | - | println!(" Total revenue: ${:.2}", dollars); | |
| 452 | + | println!(" Total revenue: {}", makenotwork::formatting::format_revenue(revenue_cents)); | |
| 455 | 453 | println!(" Total sales: {}", completed); | |
| 456 | 454 | println!(" Total refunds: {}", refunded); | |
| 457 | 455 | ||
| @@ -499,9 +497,9 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<( | |||
| 499 | 497 | } | |
| 500 | 498 | ||
| 501 | 499 | println!( | |
| 502 | - | "\n{} transaction(s), ${:.2} total revenue.", | |
| 500 | + | "\n{} transaction(s), {} total revenue.", | |
| 503 | 501 | txs.len(), | |
| 504 | - | total_cents as f64 / 100.0 | |
| 502 | + | makenotwork::formatting::format_revenue(total_cents) | |
| 505 | 503 | ); | |
| 506 | 504 | Ok(()) | |
| 507 | 505 | } |
| @@ -0,0 +1,90 @@ | |||
| 1 | + | //! Cloudflare edge-cache purge client. | |
| 2 | + | //! | |
| 3 | + | //! Deleting a quarantined object from origin stops origin serving, but | |
| 4 | + | //! Cloudflare caches `cdn.makenot.work/{key}` immutably for up to a year, so an | |
| 5 | + | //! already-edge-cached malicious object keeps serving from the edge until its | |
| 6 | + | //! TTL lapses (ultra-fuzz CDN scan-serve residual). Purging the specific URL | |
| 7 | + | //! closes that window immediately. | |
| 8 | + | //! | |
| 9 | + | //! Optional by design: when `CF_API_TOKEN` / `CF_ZONE_ID` are not configured, | |
| 10 | + | //! [`CloudflarePurger::from_env`] returns `None` and every purge becomes a | |
| 11 | + | //! logged no-op — the malware-quarantine WAM ticket remains the manual | |
| 12 | + | //! fallback, exactly the posture before this wiring existed. | |
| 13 | + | ||
| 14 | + | use crate::helpers::HTTP_CLIENT; | |
| 15 | + | ||
| 16 | + | /// Cloudflare's documented per-request file cap for `purge_cache` by URL. | |
| 17 | + | const PURGE_FILES_PER_REQUEST: usize = 30; | |
| 18 | + | ||
| 19 | + | /// Holds the credentials needed to call the Cloudflare cache-purge API for one | |
| 20 | + | /// zone. Cheap to clone (two owned strings); intended to live in shared context. | |
| 21 | + | #[derive(Clone)] | |
| 22 | + | pub struct CloudflarePurger { | |
| 23 | + | zone_id: String, | |
| 24 | + | api_token: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl std::fmt::Debug for CloudflarePurger { | |
| 28 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 29 | + | f.debug_struct("CloudflarePurger") | |
| 30 | + | .field("zone_id", &self.zone_id) | |
| 31 | + | .field("api_token", &"[REDACTED]") | |
| 32 | + | .finish() | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl CloudflarePurger { | |
| 37 | + | /// Build from `CF_API_TOKEN` + `CF_ZONE_ID`. Returns `None` if either is | |
| 38 | + | /// unset or empty, in which case purges become logged no-ops. | |
| 39 | + | pub fn from_env() -> Option<Self> { | |
| 40 | + | let api_token = std::env::var("CF_API_TOKEN").ok().filter(|s| !s.is_empty())?; | |
| 41 | + | let zone_id = std::env::var("CF_ZONE_ID").ok().filter(|s| !s.is_empty())?; | |
| 42 | + | Some(Self { zone_id, api_token }) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Purge specific absolute URLs from Cloudflare's edge cache. | |
| 46 | + | /// | |
| 47 | + | /// Fire-and-forget at the call site (callers should not block enforcement on | |
| 48 | + | /// it); failures are logged, not propagated — the object is already gone from | |
| 49 | + | /// origin, so a failed edge purge degrades to the prior TTL-bounded residual | |
| 50 | + | /// rather than a serving failure. Batches to Cloudflare's per-request cap. | |
| 51 | + | pub async fn purge_urls(&self, urls: Vec<String>) { | |
| 52 | + | for chunk in urls.chunks(PURGE_FILES_PER_REQUEST) { | |
| 53 | + | self.purge_chunk(chunk).await; | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | async fn purge_chunk(&self, urls: &[String]) { | |
| 58 | + | if urls.is_empty() { | |
| 59 | + | return; | |
| 60 | + | } | |
| 61 | + | let endpoint = format!( | |
| 62 | + | "https://api.cloudflare.com/client/v4/zones/{}/purge_cache", | |
| 63 | + | self.zone_id | |
| 64 | + | ); | |
| 65 | + | let body = serde_json::json!({ "files": urls }); | |
| 66 | + | match HTTP_CLIENT | |
| 67 | + | .post(&endpoint) | |
| 68 | + | .bearer_auth(&self.api_token) | |
| 69 | + | .json(&body) | |
| 70 | + | .send() | |
| 71 | + | .await | |
| 72 | + | { | |
| 73 | + | Ok(resp) if resp.status().is_success() => { | |
| 74 | + | tracing::info!(count = urls.len(), "cloudflare edge-cache purge succeeded"); | |
| 75 | + | } | |
| 76 | + | Ok(resp) => { | |
| 77 | + | let status = resp.status(); | |
| 78 | + | let detail = resp.text().await.unwrap_or_default(); | |
| 79 | + | tracing::error!( | |
| 80 | + | %status, detail = %detail, count = urls.len(), | |
| 81 | + | "cloudflare edge-cache purge returned an error; quarantined URL may stay edge-cached until TTL" | |
| 82 | + | ); | |
| 83 | + | } | |
| 84 | + | Err(e) => tracing::error!( | |
| 85 | + | error = ?e, count = urls.len(), | |
| 86 | + | "cloudflare edge-cache purge request failed; quarantined URL may stay edge-cached until TTL" | |
| 87 | + | ), | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } |
| @@ -16,6 +16,12 @@ pub async fn create_collection( | |||
| 16 | 16 | description: Option<&str>, | |
| 17 | 17 | is_public: bool, | |
| 18 | 18 | ) -> Result<DbCollection> { | |
| 19 | + | // Collections intentionally REJECT a slug clash rather than auto-suffix | |
| 20 | + | // (unlike projects/items/sections): the slug is user-chosen and they should | |
| 21 | + | // be told it's taken. Map the per-user unique violation to a clean | |
| 22 | + | // validation error here so every caller — web and the CLI/service route — | |
| 23 | + | // gets the same 400 instead of a raw 500. This is the seal: no caller can | |
| 24 | + | // surface an unhandled 23505 from a collection insert. | |
| 19 | 25 | let collection = sqlx::query_as::<_, DbCollection>( | |
| 20 | 26 | r#" | |
| 21 | 27 | INSERT INTO collections (user_id, slug, title, description, is_public) | |
| @@ -29,7 +35,17 @@ pub async fn create_collection( | |||
| 29 | 35 | .bind(description) | |
| 30 | 36 | .bind(is_public) | |
| 31 | 37 | .fetch_one(pool) | |
| 32 | - | .await?; | |
| 38 | + | .await | |
| 39 | + | .map_err(|e| { | |
| 40 | + | let e = crate::error::AppError::from(e); | |
| 41 | + | if crate::helpers::is_unique_violation(&e) { | |
| 42 | + | crate::error::AppError::validation( | |
| 43 | + | "You already have a collection with this slug".to_string(), | |
| 44 | + | ) | |
| 45 | + | } else { | |
| 46 | + | e | |
| 47 | + | } | |
| 48 | + | })?; | |
| 33 | 49 | ||
| 34 | 50 | Ok(collection) | |
| 35 | 51 | } |
| @@ -20,23 +20,32 @@ pub async fn create_project( | |||
| 20 | 20 | features: &[String], | |
| 21 | 21 | ) -> Result<DbProject> { | |
| 22 | 22 | let project_type = super::ProjectFeature::derive_project_type(features); | |
| 23 | - | let project = sqlx::query_as::<_, DbProject>( | |
| 24 | - | r#" | |
| 25 | - | INSERT INTO projects (user_id, slug, title, description, project_type, features) | |
| 26 | - | VALUES ($1, $2, $3, $4, $5, $6) | |
| 27 | - | RETURNING * | |
| 28 | - | "#, | |
| 29 | - | ) | |
| 30 | - | .bind(user_id) | |
| 31 | - | .bind(slug) | |
| 32 | - | .bind(title) | |
| 33 | - | .bind(description) | |
| 34 | - | .bind(project_type) | |
| 35 | - | .bind(features) | |
| 36 | - | .fetch_one(pool) | |
| 37 | - | .await?; | |
| 38 | - | ||
| 39 | - | Ok(project) | |
| 23 | + | // Slug uniqueness is enforced by the per-table unique indexes — including the | |
| 24 | + | // cross-creator `idx_projects_public_slug` (migration 062). Route the bare | |
| 25 | + | // INSERT through `insert_with_unique_slug` so a collision auto-suffixes | |
| 26 | + | // (`slug`, `slug-2`, ...) and retries instead of surfacing a raw 500 (the | |
| 27 | + | // CHRONIC slug-dedup drift, ultra-fuzz Run 2 UX). This is the seal: there is | |
| 28 | + | // no public bare-insert constructor for projects. | |
| 29 | + | crate::helpers::insert_with_unique_slug(slug.as_str(), |candidate| async move { | |
| 30 | + | let candidate = Slug::from_trusted(candidate); | |
| 31 | + | sqlx::query_as::<_, DbProject>( | |
| 32 | + | r#" | |
| 33 | + | INSERT INTO projects (user_id, slug, title, description, project_type, features) | |
| 34 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 35 | + | RETURNING * | |
| 36 | + | "#, | |
| 37 | + | ) | |
| 38 | + | .bind(user_id) | |
| 39 | + | .bind(&candidate) | |
| 40 | + | .bind(title) | |
| 41 | + | .bind(description) | |
| 42 | + | .bind(project_type) | |
| 43 | + | .bind(features) | |
| 44 | + | .fetch_one(pool) | |
| 45 | + | .await | |
| 46 | + | .map_err(Into::into) | |
| 47 | + | }) | |
| 48 | + | .await | |
| 40 | 49 | } | |
| 41 | 50 | ||
| 42 | 51 | /// Fetch a project by primary key. Returns `None` if not found. |
| @@ -835,8 +835,13 @@ pub async fn get_transaction_by_id( | |||
| 835 | 835 | /// | |
| 836 | 836 | /// Returns ALL refunded transactions (handles cart checkouts where multiple | |
| 837 | 837 | /// transactions share the same payment_intent_id). | |
| 838 | + | /// | |
| 839 | + | /// FULL-INTENT scope, and `pub(crate)` so only in-crate webhook handlers can | |
| 840 | + | /// mint it: a single cart line must use the line-scoped | |
| 841 | + | /// [`refund_transaction_by_id`] instead, never this PI-wide UPDATE (the Run #2 | |
| 842 | + | /// Payments SERIOUS that refunded a whole cart from one line's event). | |
| 838 | 843 | #[tracing::instrument(skip_all)] | |
| 839 | - | pub async fn refund_transaction_by_payment_intent<'e>( | |
| 844 | + | pub(crate) async fn refund_transaction_by_payment_intent<'e>( | |
| 840 | 845 | executor: impl sqlx::PgExecutor<'e>, | |
| 841 | 846 | payment_intent_id: &str, | |
| 842 | 847 | ) -> Result<Vec<(super::TransactionId, Option<ItemId>)>> { | |
| @@ -865,7 +870,7 @@ pub async fn refund_transaction_by_payment_intent<'e>( | |||
| 865 | 870 | /// payment_intent, so refunding one line must touch only its own row — never the | |
| 866 | 871 | /// PI-wide [`refund_transaction_by_payment_intent`] (Run #2 Payments SERIOUS). | |
| 867 | 872 | #[tracing::instrument(skip_all)] | |
| 868 | - | pub async fn refund_transaction_by_id<'e>( | |
| 873 | + | pub(crate) async fn refund_transaction_by_id<'e>( | |
| 869 | 874 | executor: impl sqlx::PgExecutor<'e>, | |
| 870 | 875 | id: TransactionId, | |
| 871 | 876 | ) -> Result<Option<(super::TransactionId, Option<ItemId>)>> { |
| @@ -16,6 +16,48 @@ fn greeting(name: Option<&str>) -> String { | |||
| 16 | 16 | name.map(|n| format!(" {}", n)).unwrap_or_default() | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | /// A recipient list proven to be within [`BROADCAST_MAX_RECIPIENTS`](crate::constants::BROADCAST_MAX_RECIPIENTS). | |
| 20 | + | /// | |
| 21 | + | /// Broadcasts must not materialize an unbounded recipient set in memory and | |
| 22 | + | /// fan out unthrottled email. The only way to obtain this type is | |
| 23 | + | /// [`BoundedRecipients::new`], which enforces the cap at construction — so a new | |
| 24 | + | /// broadcast site physically cannot skip the check. This replaces the | |
| 25 | + | /// copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` guards that had drifted | |
| 26 | + | /// between the public and internal broadcast handlers (the cap constant now | |
| 27 | + | /// lives only in this constructor; a grep guard below enforces that). On | |
| 28 | + | /// overflow `new` returns the actual recipient count so the caller can build a | |
| 29 | + | /// user-facing message and roll back any rate-limit slot it already consumed. | |
| 30 | + | #[derive(Debug)] | |
| 31 | + | pub struct BoundedRecipients<T>(Vec<T>); | |
| 32 | + | ||
| 33 | + | impl<T> BoundedRecipients<T> { | |
| 34 | + | /// Construct from a raw recipient list, enforcing the broadcast cap. | |
| 35 | + | /// Returns `Err(count)` with the actual recipient count on overflow. | |
| 36 | + | pub fn new(recipients: Vec<T>) -> std::result::Result<Self, usize> { | |
| 37 | + | let n = recipients.len(); | |
| 38 | + | if n > crate::constants::BROADCAST_MAX_RECIPIENTS { | |
| 39 | + | Err(n) | |
| 40 | + | } else { | |
| 41 | + | Ok(Self(recipients)) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Number of recipients (always `<= BROADCAST_MAX_RECIPIENTS`). | |
| 46 | + | pub fn len(&self) -> usize { | |
| 47 | + | self.0.len() | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Whether the recipient list is empty. | |
| 51 | + | pub fn is_empty(&self) -> bool { | |
| 52 | + | self.0.is_empty() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Consume into the inner recipient vector for fan-out iteration. | |
| 56 | + | pub fn into_inner(self) -> Vec<T> { | |
| 57 | + | self.0 | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 19 | 61 | /// Email service configuration | |
| 20 | 62 | #[derive(Clone)] | |
| 21 | 63 | pub struct EmailConfig { | |
| @@ -338,3 +380,91 @@ impl EmailTransport for PostmarkTransport { | |||
| 338 | 380 | self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast")).await | |
| 339 | 381 | } | |
| 340 | 382 | } | |
| 383 | + | ||
| 384 | + | #[cfg(test)] | |
| 385 | + | mod bounded_recipients_tests { | |
| 386 | + | use super::*; | |
| 387 | + | ||
| 388 | + | #[test] | |
| 389 | + | fn accepts_under_cap() { | |
| 390 | + | let r = BoundedRecipients::new(vec![1, 2, 3]).expect("under cap"); | |
| 391 | + | assert_eq!(r.len(), 3); | |
| 392 | + | assert!(!r.is_empty()); | |
| 393 | + | assert_eq!(r.into_inner(), vec![1, 2, 3]); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | #[test] | |
| 397 | + | fn accepts_exactly_at_cap() { | |
| 398 | + | let v = vec![0u8; crate::constants::BROADCAST_MAX_RECIPIENTS]; | |
| 399 | + | assert!(BoundedRecipients::new(v).is_ok()); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn rejects_over_cap_with_count() { | |
| 404 | + | let n = crate::constants::BROADCAST_MAX_RECIPIENTS + 1; | |
| 405 | + | let v = vec![0u8; n]; | |
| 406 | + | assert_eq!(BoundedRecipients::new(v).unwrap_err(), n); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn empty_is_allowed() { | |
| 411 | + | let r = BoundedRecipients::<u8>::new(vec![]).expect("empty ok"); | |
| 412 | + | assert!(r.is_empty()); | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | /// Seal for the broadcast recipient cap. | |
| 417 | + | /// | |
| 418 | + | /// The cap was a copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` check that had | |
| 419 | + | /// drifted between the public and internal broadcast handlers. The fix routes | |
| 420 | + | /// both through [`BoundedRecipients`], so the constant must appear ONLY in its | |
| 421 | + | /// definition (`constants.rs`) and this module's constructor. This test fails | |
| 422 | + | /// the build if any other file references the constant — forcing a new broadcast | |
| 423 | + | /// site through the sealed constructor instead of re-inlining the check. | |
| 424 | + | #[cfg(test)] | |
| 425 | + | mod broadcast_cap_seal_guard { | |
| 426 | + | use std::path::Path; | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn cap_constant_used_only_in_sealed_constructor() { | |
| 430 | + | let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); | |
| 431 | + | let allowed = [ | |
| 432 | + | Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs"), | |
| 433 | + | Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"), | |
| 434 | + | ]; | |
| 435 | + | let mut offenders = Vec::new(); | |
| 436 | + | walk(&src_dir, &mut |path, contents| { | |
| 437 | + | if allowed.iter().any(|a| a == path) { | |
| 438 | + | return; | |
| 439 | + | } | |
| 440 | + | for (i, line) in contents.lines().enumerate() { | |
| 441 | + | if line.contains("BROADCAST_MAX_RECIPIENTS") { | |
| 442 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | }); | |
| 446 | + | assert!( | |
| 447 | + | offenders.is_empty(), | |
| 448 | + | "broadcast-cap seal violated — enforce the recipient cap via \ | |
| 449 | + | email::BoundedRecipients::new, never by referencing BROADCAST_MAX_RECIPIENTS \ | |
| 450 | + | directly in a handler. Offending lines:\n{}", | |
| 451 | + | offenders.join("\n") | |
| 452 | + | ); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 456 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 457 | + | return; | |
| 458 | + | }; | |
| 459 | + | for entry in entries.flatten() { | |
| 460 | + | let path = entry.path(); | |
| 461 | + | if path.is_dir() { | |
| 462 | + | walk(&path, f); | |
| 463 | + | } else if path.extension().is_some_and(|e| e == "rs") | |
| 464 | + | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 465 | + | { | |
| 466 | + | f(&path, &contents); | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } |
| @@ -46,6 +46,25 @@ pub fn format_revenue(cents: i64) -> String { | |||
| 46 | 46 | format!("{sign}${dollars}.{frac:02}") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /// Format a price in cents as a plain decimal string: no currency symbol, no | |
| 50 | + | /// thousands separators, always two decimal places (e.g. "9.99", "1234.50"). | |
| 51 | + | /// | |
| 52 | + | /// For CSV cells and form `value=` attributes where a bare numeric is required | |
| 53 | + | /// and the surrounding context (spreadsheet column, template `$` prefix) | |
| 54 | + | /// supplies its own framing. For human-facing display use [`format_price`] | |
| 55 | + | /// (shows "Free" / drops trailing `.00`) or [`format_revenue`] (always `$X.XX`). | |
| 56 | + | /// | |
| 57 | + | /// This is the canonical cents→decimal conversion: bypassing it with a raw | |
| 58 | + | /// `as f64 / 100.0` is what the pricing-format drift chronic keeps re-finding, | |
| 59 | + | /// so a grep-enforcing test (below) bans that idiom outside this module. | |
| 60 | + | pub fn format_dollars_plain(cents: impl Into<i64>) -> String { | |
| 61 | + | let cents: i64 = cents.into(); | |
| 62 | + | let neg = cents < 0; | |
| 63 | + | let abs = cents.unsigned_abs(); | |
| 64 | + | let sign = if neg { "-" } else { "" }; | |
| 65 | + | format!("{sign}{}.{:02}", abs / 100, (abs % 100) as u32) | |
| 66 | + | } | |
| 67 | + | ||
| 49 | 68 | /// Format a byte count as a human-readable file size string. | |
| 50 | 69 | /// Returns "N/A" for zero bytes (useful for optional file sizes). | |
| 51 | 70 | pub fn format_file_size(bytes: i64) -> String { | |
| @@ -168,6 +187,29 @@ pub fn sanitize_csv_cell(value: &str) -> String { | |||
| 168 | 187 | mod tests { | |
| 169 | 188 | use super::*; | |
| 170 | 189 | ||
| 190 | + | // ── format_dollars_plain ── | |
| 191 | + | ||
| 192 | + | #[test] | |
| 193 | + | fn dollars_plain_basic() { | |
| 194 | + | assert_eq!(format_dollars_plain(0), "0.00"); | |
| 195 | + | assert_eq!(format_dollars_plain(999), "9.99"); | |
| 196 | + | assert_eq!(format_dollars_plain(100), "1.00"); | |
| 197 | + | assert_eq!(format_dollars_plain(1), "0.01"); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn dollars_plain_no_thousands_separator() { | |
| 202 | + | // CSV cells and form values must stay machine-parseable — no commas. | |
| 203 | + | assert_eq!(format_dollars_plain(123_456), "1234.56"); | |
| 204 | + | assert_eq!(format_dollars_plain(1_000_000), "10000.00"); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | #[test] | |
| 208 | + | fn dollars_plain_negative() { | |
| 209 | + | assert_eq!(format_dollars_plain(-999), "-9.99"); | |
| 210 | + | assert_eq!(format_dollars_plain(-5), "-0.05"); | |
| 211 | + | } | |
| 212 | + | ||
| 171 | 213 | // ── format_price ── | |
| 172 | 214 | ||
| 173 | 215 | #[test] | |
| @@ -714,3 +756,64 @@ mod tests { | |||
| 714 | 756 | } | |
| 715 | 757 | } | |
| 716 | 758 | } | |
| 759 | + | ||
| 760 | + | /// Seal for the pricing-format drift chronic. | |
| 761 | + | /// | |
| 762 | + | /// The audit kept re-finding ad-hoc `cents as f64 / 100.0` conversions scattered | |
| 763 | + | /// across routes, exports, and admin tooling — each one a place where a future | |
| 764 | + | /// edit could silently reintroduce a rounding or display inconsistency. This | |
| 765 | + | /// test fails the build if that idiom appears anywhere under `src/` outside this | |
| 766 | + | /// module, forcing every cents→dollars conversion through [`format_price`], | |
| 767 | + | /// [`format_revenue`], or [`format_dollars_plain`]. The seal is resolved when the | |
| 768 | + | /// drifted variant can no longer compile-and-pass. | |
| 769 | + | #[cfg(test)] | |
| 770 | + | mod pricing_format_seal_guard { | |
| 771 | + | use std::path::Path; | |
| 772 | + | ||
| 773 | + | #[test] | |
| 774 | + | fn no_ad_hoc_cents_to_dollars_conversion() { | |
| 775 | + | let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); | |
| 776 | + | let this_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/formatting.rs"); | |
| 777 | + | let mut offenders = Vec::new(); | |
| 778 | + | walk(&src_dir, &mut |path, contents| { | |
| 779 | + | // This module documents the banned idiom; skip it. | |
| 780 | + | if path == this_file { | |
| 781 | + | return; | |
| 782 | + | } | |
| 783 | + | for (i, line) in contents.lines().enumerate() { | |
| 784 | + | // Comment/doc lines may legitimately mention the idiom. | |
| 785 | + | if line.trim_start().starts_with("//") { | |
| 786 | + | continue; | |
| 787 | + | } | |
| 788 | + | // Normalize whitespace so spacing variations all match. | |
| 789 | + | let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 790 | + | if squished.contains("asf64/100") { | |
| 791 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | }); | |
| 795 | + | assert!( | |
| 796 | + | offenders.is_empty(), | |
| 797 | + | "pricing-format seal violated — convert cents to dollars via \ | |
| 798 | + | formatting::format_price / format_revenue / format_dollars_plain, never a raw \ | |
| 799 | + | `as f64 / 100.0`. Offending lines:\n{}", | |
| 800 | + | offenders.join("\n") | |
| 801 | + | ); | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 805 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 806 | + | return; | |
| 807 | + | }; | |
| 808 | + | for entry in entries.flatten() { | |
| 809 | + | let path = entry.path(); | |
| 810 | + | if path.is_dir() { | |
| 811 | + | walk(&path, f); | |
| 812 | + | } else if path.extension().is_some_and(|e| e == "rs") | |
| 813 | + | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 814 | + | { | |
| 815 | + | f(&path, &contents); | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | } | |
| 819 | + | } |
| @@ -235,7 +235,12 @@ fn validate_segment(s: &str) -> Result<(), GitError> { | |||
| 235 | 235 | // ============================================================================ | |
| 236 | 236 | ||
| 237 | 237 | /// Open a bare repository with path traversal validation. | |
| 238 | - | pub fn open_repo(repos_root: &Path, owner: &str, repo: &str) -> Result<Repository, GitError> { | |
| 238 | + | /// | |
| 239 | + | /// `pub(crate)` by design: `git2::Repository` is `!Send` and blocks, so every | |
| 240 | + | /// caller must hold it inside a `spawn_blocking` closure (see | |
| 241 | + | /// `routes::git::GitState::with_repo`). Keeping the opener crate-private marks | |
| 242 | + | /// it as an internal primitive, not a general-purpose entry point. | |
| 243 | + | pub(crate) fn open_repo(repos_root: &Path, owner: &str, repo: &str) -> Result<Repository, GitError> { | |
| 239 | 244 | validate_segment(owner)?; | |
| 240 | 245 | validate_segment(repo)?; | |
| 241 | 246 | ||
| @@ -260,7 +265,7 @@ pub fn open_repo(repos_root: &Path, owner: &str, repo: &str) -> Result<Repositor | |||
| 260 | 265 | /// returned by [`repo_disk_path`]). Used to (re)open the repo inside a | |
| 261 | 266 | /// `spawn_blocking` closure, since `git2::Repository` is `!Send` and cannot | |
| 262 | 267 | /// cross the await boundary. | |
| 263 | - | pub fn open_repo_at(repo_path: &Path) -> Result<Repository, GitError> { | |
| 268 | + | pub(crate) fn open_repo_at(repo_path: &Path) -> Result<Repository, GitError> { | |
| 264 | 269 | Repository::open_bare(repo_path).map_err(|_| GitError::RepoNotFound) | |
| 265 | 270 | } | |
| 266 | 271 |
| @@ -4,6 +4,7 @@ pub mod access_gate; | |||
| 4 | 4 | pub mod auth; | |
| 5 | 5 | pub mod background; | |
| 6 | 6 | pub mod build_runner; | |
| 7 | + | pub mod cloudflare; | |
| 7 | 8 | pub mod config; | |
| 8 | 9 | pub mod constants; | |
| 9 | 10 | pub mod csrf; |
| @@ -402,6 +402,8 @@ async fn main() { | |||
| 402 | 402 | scan_semaphore: state.scan_semaphore.clone(), | |
| 403 | 403 | wam: state.wam.clone(), | |
| 404 | 404 | clamav_healthy: clamav_healthy.clone(), | |
| 405 | + | cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(), | |
| 406 | + | cdn_base_url: state.config.cdn_base_url.as_deref().map(std::sync::Arc::from), | |
| 405 | 407 | }); | |
| 406 | 408 | let worker_count = makenotwork::constants::SCAN_WORKER_COUNT; | |
| 407 | 409 | let worker_shutdown_rx = shutdown_tx.subscribe(); |
| @@ -117,8 +117,8 @@ pub struct SynckitAppSubCheckoutParams<'a> { | |||
| 117 | 117 | pub(crate) fn check_min_charge(amount_cents: i64) -> Result<()> { | |
| 118 | 118 | if amount_cents > 0 && amount_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS { | |
| 119 | 119 | return Err(AppError::BadRequest(format!( | |
| 120 | - | "Minimum purchase amount is ${:.2}", | |
| 121 | - | constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0 | |
| 120 | + | "Minimum purchase amount is {}", | |
| 121 | + | crate::formatting::format_revenue(constants::STRIPE_MINIMUM_CHARGE_CENTS) | |
| 122 | 122 | ))); | |
| 123 | 123 | } | |
| 124 | 124 | Ok(()) |
| @@ -319,8 +319,8 @@ impl PricingModel for PwywPricing { | |||
| 319 | 319 | let min = self.min_cents.unwrap_or(0); | |
| 320 | 320 | if amount_cents < min { | |
| 321 | 321 | return Err(format!( | |
| 322 | - | "Amount must be at least ${:.2}", | |
| 323 | - | min as f64 / 100.0 | |
| 322 | + | "Amount must be at least {}", | |
| 323 | + | crate::formatting::format_revenue(min as i64) | |
| 324 | 324 | )); | |
| 325 | 325 | } | |
| 326 | 326 | // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges |
| @@ -106,7 +106,9 @@ pub(super) async fn create_collection( | |||
| 106 | 106 | ))); | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | - | let collection = match db::collections::create_collection( | |
| 109 | + | // `create_collection` maps the per-user slug unique-violation to a clean | |
| 110 | + | // validation error (the seal lives in the db layer), so callers just `?`. | |
| 111 | + | let collection = db::collections::create_collection( | |
| 110 | 112 | &state.db, | |
| 111 | 113 | user.id, | |
| 112 | 114 | &slug, | |
| @@ -114,18 +116,7 @@ pub(super) async fn create_collection( | |||
| 114 | 116 | description, | |
| 115 | 117 | req.is_public, | |
| 116 | 118 | ) | |
| 117 | - | .await | |
| 118 | - | { | |
| 119 | - | Ok(c) => c, | |
| 120 | - | Err(crate::error::AppError::Database(sqlx::Error::Database(ref db_err))) | |
| 121 | - | if db_err.code().as_deref() == Some("23505") => | |
| 122 | - | { | |
| 123 | - | return Err(AppError::validation( | |
| 124 | - | "You already have a collection with this slug".to_string(), | |
| 125 | - | )); | |
| 126 | - | } | |
| 127 | - | Err(e) => return Err(e), | |
| 128 | - | }; | |
| 119 | + | .await?; | |
| 129 | 120 | ||
| 130 | 121 | Ok(( | |
| 131 | 122 | StatusCode::CREATED, |
| @@ -542,10 +542,10 @@ pub(super) async fn export_subscriptions( | |||
| 542 | 542 | }; | |
| 543 | 543 | ||
| 544 | 544 | csv_content.push_str(&format!( | |
| 545 | - | "{},{},{:.2},{},{},{},{},{},{}\n", | |
| 545 | + | "{},{},{},{},{},{},{},{},{}\n", | |
| 546 | 546 | sanitize_csv_cell(&s.project_title), | |
| 547 | 547 | sanitize_csv_cell(&s.tier_name), | |
| 548 | - | s.price_cents as f64 / 100.0, | |
| 548 | + | crate::formatting::format_dollars_plain(s.price_cents), | |
| 549 | 549 | sanitize_csv_cell(&s.username), | |
| 550 | 550 | sanitize_csv_cell(&s.status.to_string()), | |
| 551 | 551 | fmt_opt(s.current_period_start), | |
| @@ -583,11 +583,11 @@ pub(super) async fn export_contacts( | |||
| 583 | 583 | let mut csv_content = String::from("Username,Email,Purchases,Total Spent,Last Purchase\n"); | |
| 584 | 584 | for c in &contacts { | |
| 585 | 585 | csv_content.push_str(&format!( | |
| 586 | - | "{},{},{},{:.2},{}\n", | |
| 586 | + | "{},{},{},{},{}\n", | |
| 587 | 587 | sanitize_csv_cell(&c.username), | |
| 588 | 588 | sanitize_csv_cell(&c.email), | |
| 589 | 589 | c.total_purchases, | |
| 590 | - | c.total_spent_cents as f64 / 100.0, | |
| 590 | + | crate::formatting::format_dollars_plain(c.total_spent_cents), | |
| 591 | 591 | c.last_purchase_at.format("%Y-%m-%d"), | |
| 592 | 592 | )); | |
| 593 | 593 | } |
| @@ -179,22 +179,25 @@ pub(super) async fn send_broadcast( | |||
| 179 | 179 | return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string())); | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | + | // Enforce the broadcast recipient cap at the type level — the same | |
| 183 | + | // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs) | |
| 184 | + | // uses, so the two handlers can no longer drift on the cap check. | |
| 182 | 185 | let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?; | |
| 183 | - | let count = followers.len(); | |
| 184 | - | ||
| 185 | - | if count > constants::BROADCAST_MAX_RECIPIENTS { | |
| 186 | - | // Mirror the public twin (routes/api/users/broadcast.rs): refuse to | |
| 187 | - | // materialize an unbounded recipient set in memory, and roll back the | |
| 188 | - | // 24h rate-limit slot so the creator can retry once the cap is lifted. | |
| 189 | - | let _ = db::users::clear_broadcast_at(&state.db, req.user_id).await; | |
| 190 | - | return Err(AppError::validation(format!( | |
| 191 | - | "Broadcast would reach {count} followers, above the per-send limit of {}. \ | |
| 192 | - | Email info@makenot.work to lift the cap for your account.", | |
| 193 | - | constants::BROADCAST_MAX_RECIPIENTS | |
| 194 | - | ))); | |
| 195 | - | } | |
| 186 | + | let recipients = match crate::email::BoundedRecipients::new(followers) { | |
| 187 | + | Ok(r) => r, | |
| 188 | + | Err(count) => { | |
| 189 | + | // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted. | |
| 190 | + | let _ = db::users::clear_broadcast_at(&state.db, req.user_id).await; | |
| 191 | + | return Err(AppError::validation(format!( | |
| 192 | + | "Broadcast would reach {count} followers, above the per-send limit of 10,000. \ | |
| 193 | + | Email info@makenot.work to lift the cap for your account." | |
| 194 | + | ))); | |
| 195 | + | } | |
| 196 | + | }; | |
| 197 | + | let count = recipients.len(); | |
| 196 | 198 | ||
| 197 | 199 | if count > 0 { | |
| 200 | + | let followers = recipients.into_inner(); | |
| 198 | 201 | let creator_name = db_user.display_name.as_deref() | |
| 199 | 202 | .unwrap_or(&db_user.username) | |
| 200 | 203 | .to_string(); |
| @@ -118,7 +118,7 @@ pub(super) async fn create_promo_code( | |||
| 118 | 118 | return Err(AppError::BadRequest("Fixed discount must be at least 1 cent".to_string())); | |
| 119 | 119 | } | |
| 120 | 120 | if dv > crate::constants::MAX_PRICE_CENTS { | |
| 121 | - | return Err(AppError::BadRequest(format!("Fixed discount must be at most ${:.2}", crate::constants::MAX_PRICE_CENTS as f64 / 100.0))); | |
| 121 | + | return Err(AppError::BadRequest(format!("Fixed discount must be at most {}", crate::formatting::format_revenue(crate::constants::MAX_PRICE_CENTS as i64)))); | |
| 122 | 122 | } | |
| 123 | 123 | } | |
| 124 | 124 | } |
| @@ -66,9 +66,23 @@ pub(in crate::routes::api) async fn broadcast_send( | |||
| 66 | 66 | }.render_string()).into_response()); | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | - | // Get follower emails | |
| 69 | + | // Get follower emails, enforcing the broadcast recipient cap at the type | |
| 70 | + | // level — `BoundedRecipients::new` is the only way to obtain a sendable list. | |
| 70 | 71 | let followers = db::follows::get_follower_emails(&state.db, user.id).await?; | |
| 71 | - | let count = followers.len(); | |
| 72 | + | let recipients = match crate::email::BoundedRecipients::new(followers) { | |
| 73 | + | Ok(r) => r, | |
| 74 | + | Err(count) => { | |
| 75 | + | // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap. | |
| 76 | + | let _ = db::users::clear_broadcast_at(&state.db, user.id).await; | |
| 77 | + | return Ok(Html(FormStatusTemplate { | |
| 78 | + | success: false, | |
| 79 | + | message: format!( | |
| 80 | + | "Broadcast would reach {count} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account." | |
| 81 | + | ), | |
| 82 | + | }.render_string()).into_response()); | |
| 83 | + | } | |
| 84 | + | }; | |
| 85 | + | let count = recipients.len(); | |
| 72 | 86 | ||
| 73 | 87 | if count == 0 { | |
| 74 | 88 | return Ok(Html(FormStatusTemplate { | |
| @@ -77,18 +91,6 @@ pub(in crate::routes::api) async fn broadcast_send( | |||
| 77 | 91 | }.render_string()).into_response()); | |
| 78 | 92 | } | |
| 79 | 93 | ||
| 80 | - | if count > constants::BROADCAST_MAX_RECIPIENTS { | |
| 81 | - | // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap. | |
| 82 | - | let _ = db::users::clear_broadcast_at(&state.db, user.id).await; | |
| 83 | - | return Ok(Html(FormStatusTemplate { | |
| 84 | - | success: false, | |
| 85 | - | message: format!( | |
| 86 | - | "Broadcast would reach {} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account.", | |
| 87 | - | count | |
| 88 | - | ), | |
| 89 | - | }.render_string()).into_response()); | |
| 90 | - | } | |
| 91 | - | ||
| 92 | 94 | // Get creator name | |
| 93 | 95 | let db_user = db::users::get_user_by_id(&state.db, user.id) | |
| 94 | 96 | .await? | |
| @@ -105,6 +107,7 @@ pub(in crate::routes::api) async fn broadcast_send( | |||
| 105 | 107 | let host_url = state.config.host_url.clone(); | |
| 106 | 108 | let signing_secret = state.config.signing_secret.clone(); | |
| 107 | 109 | ||
| 110 | + | let followers = recipients.into_inner(); | |
| 108 | 111 | tokio::spawn(async move { | |
| 109 | 112 | let mut set = tokio::task::JoinSet::new(); | |
| 110 | 113 | let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS); |
| @@ -203,15 +203,14 @@ mod tests { | |||
| 203 | 203 | ||
| 204 | 204 | #[test] | |
| 205 | 205 | fn price_display_fixed() { | |
| 206 | - | let cents = 1999; | |
| 207 | - | let price = format!("${:.2}", cents as f64 / 100.0); | |
| 206 | + | let price = crate::formatting::format_revenue(1999); | |
| 208 | 207 | assert_eq!(price, "$19.99"); | |
| 209 | 208 | } | |
| 210 | 209 | ||
| 211 | 210 | #[test] | |
| 212 | 211 | fn price_display_pwyw() { | |
| 213 | - | let cents = 500; | |
| 214 | - | let price = format!("${:.2}+", cents as f64 / 100.0); | |
| 212 | + | // Embed PWYW prices append "+" to the sealed revenue string. | |
| 213 | + | let price = format!("{}+", crate::formatting::format_revenue(500)); | |
| 215 | 214 | assert_eq!(price, "$5.00+"); | |
| 216 | 215 | } | |
| 217 | 216 |
| @@ -338,12 +338,12 @@ pub(super) async fn project_tab_settings( | |||
| 338 | 338 | ||
| 339 | 339 | let pricing_model = db_project.pricing_model.to_string(); | |
| 340 | 340 | let price_dollars = if db_project.price_cents > 0 { | |
| 341 | - | format!("{:.2}", db_project.price_cents as f64 / 100.0) | |
| 341 | + | crate::formatting::format_dollars_plain(db_project.price_cents) | |
| 342 | 342 | } else { | |
| 343 | 343 | String::new() | |
| 344 | 344 | }; | |
| 345 | 345 | let pwyw_min_dollars = match db_project.pwyw_min_cents { | |
| 346 | - | Some(c) if c > 0 => format!("{:.2}", c as f64 / 100.0), | |
| 346 | + | Some(c) if c > 0 => crate::formatting::format_dollars_plain(c), | |
| 347 | 347 | _ => String::new(), | |
| 348 | 348 | }; | |
| 349 | 349 |
| @@ -179,15 +179,15 @@ pub(super) async fn purchase_page( | |||
| 179 | 179 | // Calculate fee breakdown for transparency | |
| 180 | 180 | let (stripe_fee_cents, creator_receives_cents) = | |
| 181 | 181 | crate::helpers::estimate_stripe_fee(price_cents); | |
| 182 | - | let stripe_fee = format!("{:.2}", stripe_fee_cents as f64 / 100.0); | |
| 183 | - | let creator_receives = format!("{:.2}", creator_receives_cents as f64 / 100.0); | |
| 182 | + | let stripe_fee = crate::formatting::format_dollars_plain(stripe_fee_cents); | |
| 183 | + | let creator_receives = crate::formatting::format_dollars_plain(creator_receives_cents); | |
| 184 | 184 | ||
| 185 | 185 | let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?; | |
| 186 | 186 | let item = Item::from_db_list(&db_item, &purchase_tags, price_cents == 0, false); | |
| 187 | 187 | ||
| 188 | - | let suggested_price = format!("{:.2}", db_item.price_cents as f64 / 100.0); | |
| 188 | + | let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); | |
| 189 | 189 | let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0); | |
| 190 | - | let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0); | |
| 190 | + | let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min); | |
| 191 | 191 | ||
| 192 | 192 | let pending_started = if let Some(ref u) = maybe_user { | |
| 193 | 193 | match db::transactions::get_pending_item_purchase(&state.db, u.id, id).await? { | |
| @@ -261,7 +261,7 @@ pub(super) async fn receipt_page( | |||
| 261 | 261 | let amount = if is_free { | |
| 262 | 262 | "Free".to_string() | |
| 263 | 263 | } else { | |
| 264 | - | format!("${:.2}", amount_cents as f64 / 100.0) | |
| 264 | + | crate::formatting::format_revenue(amount_cents) | |
| 265 | 265 | }; | |
| 266 | 266 | ||
| 267 | 267 | let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default(); | |
| @@ -365,9 +365,9 @@ pub(super) async fn buy_page( | |||
| 365 | 365 | let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?; | |
| 366 | 366 | let item = Item::from_db_list(&db_item, &purchase_tags, db_item.price_cents == 0, false); | |
| 367 | 367 | ||
| 368 | - | let suggested_price = format!("{:.2}", db_item.price_cents as f64 / 100.0); | |
| 368 | + | let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); | |
| 369 | 369 | let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0); | |
| 370 | - | let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0); | |
| 370 | + | let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min); | |
| 371 | 371 | ||
| 372 | 372 | Ok(BuyPageTemplate { | |
| 373 | 373 | item, |