//! Shared utility functions used across routes and modules. //! //! Formatting, crypto, and rate limiting live in their own modules. //! Re-exported here for backward compatibility with existing `crate::helpers::*` imports. use axum::http::header::HeaderMap; use axum::http::HeaderValue; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use tower_sessions::Session; use crate::AppState; pub use crate::formatting::{ format_bytes, format_file_size, format_price, format_revenue, get_initials, sanitize_csv_cell, slugify, }; pub use crate::crypto::{ constant_time_compare, generate_feed_url, generate_key_code, verify_feed_signature, }; pub use crate::rate_limit::{ rate_limiter_ms, rate_limiter_per_sec, synckit_app_rate_limiter_ms, CloudflareIpKeyExtractor, SyncAppKeyExtractor, }; /// Extract the client IP from request headers. /// /// Honors `CF-Connecting-IP` only — and that header is trustworthy on every /// public path: the makenot.work blocks enforce Cloudflare mTLS (only /// Cloudflare reaches the origin, and it sets the header), and the custom-domain /// `:443` block overwrites `CF-Connecting-IP` with the real TCP peer + strips /// `X-Forwarded-For` before proxying (see `deploy/Caddyfile`). `X-Forwarded-For` /// is intentionally never consulted: there is no trusted-proxy allowlist, so a /// request reaching the app with a client-set XFF could spoof the IP and evade /// sandbox caps / poison audit logs / forge "new device" notifications. /// /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare /// was bypassed or misconfigured — keying rate-limits on `None` then collapses /// every requester into the same bucket. After 100 cumulative missing-header /// requests, emit a one-shot WARN so the operator notices before any limit /// surface degrades silently. Dev hits this immediately, which is fine: it's /// a real signal the deployment isn't behind Cloudflare. pub fn extract_client_ip(headers: &HeaderMap) -> Option { let ip = headers .get("cf-connecting-ip") .and_then(|v| v.to_str().ok()) .and_then(|s| s.split(',').next()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); if ip.is_none() { static MISSING_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); let n = MISSING_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; if n >= 100 { WARNED.get_or_init(|| { tracing::warn!( missing_count = n, "cf-connecting-ip header missing on 100+ requests — rate-limits and \ sandbox caps will key on None. Verify Cloudflare proxy is in front \ of the origin (dev/test environments hit this naturally and can ignore)." ); }); } } ip } /// Derive a stable i64 key from an IP string for use with PostgreSQL advisory locks. /// /// Uses SHA-256 rather than `std::collections::hash_map::DefaultHasher` — /// `DefaultHasher`'s algorithm is implementation-defined and can shift between /// Rust releases, which would silently change the lock keyspace on rebuild /// and let two concurrent operations from the same IP grab different locks /// across a deploy boundary. SHA-256 is stable forever. pub fn ip_advisory_lock_key(ip: &str) -> i64 { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(b"sandbox_ip_cap\0"); h.update(ip.as_bytes()); let digest = h.finalize(); // Take the first 8 bytes as an i64 (big-endian). The full SHA-256 output // is 32 bytes; the leading 8 are uniformly random over the input space. i64::from_be_bytes(digest[..8].try_into().expect("sha256 yields >= 8 bytes")) } /// Check whether the incoming request was made by HTMX. pub fn is_htmx_request(headers: &HeaderMap) -> bool { headers.get("HX-Request").is_some() } /// Check the client's `If-None-Match` header against a cache generation. /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh. pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option { let etag = format!("\"g{}\"", generation); if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) && if_none_match.as_bytes() == etag.as_bytes() { return Some( ( StatusCode::NOT_MODIFIED, [(axum::http::header::ETAG, HeaderValue::try_from(&etag).unwrap_or_else(|_| HeaderValue::from_static("invalid")))], ) .into_response(), ); } None } /// Wrap a rendered response with ETag and Cache-Control headers. /// `no-cache` tells the browser to store the response but revalidate on each use. pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response { let etag = format!("\"g{}\"", generation); ( [ (axum::http::header::ETAG, etag), (axum::http::header::CACHE_CONTROL, "private, no-cache".to_string()), ], body, ) .into_response() } /// Get or create a CSRF token for the session, returning `None` on failure. /// /// Convenience wrapper for templates that need an `Option`. pub async fn get_csrf_token(session: &Session) -> Option { crate::csrf::get_or_create_token(session).await.ok() } /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now. pub fn stripe_timestamp(ts: i64) -> chrono::DateTime { chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now) } /// Parse an optional datetime string for scheduled publishing. /// /// Accepts ISO 8601 (RFC 3339) or HTML `datetime-local` format (`%Y-%m-%dT%H:%M`). /// Returns `Some(Some(dt))` for a valid datetime, `Some(None)` for empty string /// (clear schedule), or `None` for absent input (no change). pub fn parse_schedule_datetime(s: Option<&str>) -> Option>> { s.map(|s| { if s.is_empty() { None } else { chrono::DateTime::parse_from_rfc3339(s) .map(|dt| dt.with_timezone(&chrono::Utc)) .or_else(|_| { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M") .map(|naive| naive.and_utc()) }) .ok() } }) } /// Estimate Stripe's processing fee and the net amount the creator receives. /// /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard /// Stripe rate from [`constants`](crate::constants). pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) { if price_cents <= 0 { return (0, 0); } let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32; let creator_receives = (price_cents - fee).max(0); (fee.min(price_cents), creator_receives) } /// Build an HTMX response that shows a toast notification with an empty body. /// /// Use for delete/action endpoints that only need to signal success via toast. pub fn htmx_toast_response( message: &str, toast_type: &str, ) -> ([(&'static str, HeaderValue); 1], axum::response::Html) { ([("HX-Trigger", hx_toast(message, toast_type))], axum::response::Html(String::new())) } pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue { let json = serde_json::json!({ "showToast": { "message": message, "type": toast_type } }) .to_string(); HeaderValue::from_str(&json).unwrap_or_else(|e| { tracing::warn!(message, error = %e, "hx_toast produced invalid header value"); HeaderValue::from_static("") }) } /// Fetch MT discussion thread stats (URL + post count) for a linked thread. /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread. pub async fn fetch_discussion_info( state: &AppState, mt_thread_id: Option, project_slug: &str, category_slug: &str, ) -> (Option, Option) { let Some(thread_id) = mt_thread_id else { return (None, None); }; let Some(ref mt) = state.mt_client else { return (None, None); }; let Some(ref mt_base_url) = state.config.mt_base_url else { return (None, None); }; let url = format!( "{}/p/{}/{}/{}", mt_base_url, project_slug, category_slug, thread_id ); match tokio::time::timeout( std::time::Duration::from_secs(2), mt.get_thread_stats(thread_id), ) .await { Ok(Ok(stats)) => (Some(url), Some(stats.post_count)), Ok(Err(e)) => { tracing::debug!(error = ?e, "failed to fetch MT thread stats"); (Some(url), None) } Err(_) => { tracing::debug!("MT thread stats request timed out"); (Some(url), None) } } } /// Fire-and-forget email send via the bounded background-task queue. The /// caller passes `state` (anything that exposes `.email` and `.bg`) so the /// task is bound by the global background concurrency cap rather than /// `tokio::spawn`'d unbounded — Run #8 surfaced webhook bursts that could /// otherwise spawn hundreds of detached email tasks competing with request /// handlers for the DB pool. /// /// Usage: /// ```ignore /// spawn_email!(state, "lockout notification", |email| { /// email.send_lockout_notification(&to, name.as_deref(), Some(&url)) /// }); /// ``` macro_rules! spawn_email { ($state:expr, $context:literal, |$e:ident| $body:expr) => {{ let $e = $state.email.clone(); $state.bg.spawn($context, async move { if let Err(e) = $body.await { tracing::error!(error = ?e, concat!("failed to send ", $context)); } }); }}; } pub(crate) use spawn_email; #[cfg(test)] mod tests { use super::*; // ── is_htmx_request ── #[test] fn htmx_request_detected() { let mut headers = HeaderMap::new(); headers.insert("HX-Request", HeaderValue::from_static("true")); assert!(is_htmx_request(&headers)); } #[test] fn non_htmx_request() { let headers = HeaderMap::new(); assert!(!is_htmx_request(&headers)); } // ── hx_toast ── #[test] fn hx_toast_produces_valid_json() { let val = hx_toast("Item deleted", "success"); let s = val.to_str().unwrap(); assert!(s.contains("showToast")); assert!(s.contains("Item deleted")); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["message"], "Item deleted"); assert_eq!(parsed["showToast"]["type"], "success"); } #[test] fn hx_toast_error_type() { let val = hx_toast("Something failed", "error"); let s = val.to_str().unwrap(); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["type"], "error"); } #[test] fn hx_toast_with_quotes() { let val = hx_toast("Say \"hello\"", "info"); let s = val.to_str().unwrap(); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["message"], "Say \"hello\""); } #[test] fn adversarial_hx_toast_json_injection() { let val = hx_toast("\"},{\"malicious\":\"true", "error"); let s = val.to_str().unwrap(); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true"); } // ── estimate_stripe_fee ── #[test] fn stripe_fee_standard_price() { let (fee, receives) = estimate_stripe_fee(1000); assert_eq!(fee, 59); assert_eq!(receives, 941); } #[test] fn stripe_fee_small_price() { let (fee, receives) = estimate_stripe_fee(100); assert_eq!(fee, 32); assert_eq!(receives, 68); } #[test] fn stripe_fee_zero_is_free() { let (fee, receives) = estimate_stripe_fee(0); assert_eq!(fee, 0); assert_eq!(receives, 0); } #[test] fn stripe_fee_negative_price() { let (fee, receives) = estimate_stripe_fee(-100); assert_eq!(fee, 0); assert_eq!(receives, 0); } #[test] fn stripe_fee_plus_receives_equals_price() { for price in [50, 100, 250, 500, 999, 1000, 2500, 5000, 10000, 50000] { let (fee, receives) = estimate_stripe_fee(price); assert_eq!(fee + receives, price, "fee + receives should equal price for {} cents", price); } } // ── extract_client_ip ── #[test] fn extract_client_ip_cf_preferred() { let mut headers = HeaderMap::new(); headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4")); headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8")); assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4")); } #[test] fn extract_client_ip_ignores_xff_when_cf_missing() { // XFF alone must not be trusted — see security note on extract_client_ip. let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8, 9.10.11.12")); assert_eq!(extract_client_ip(&headers), None); } #[test] fn extract_client_ip_ignores_xff_even_when_cf_present() { // Defense in depth: presence of XFF must not influence the result. let mut headers = HeaderMap::new(); headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4")); headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8")); assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4")); } #[test] fn extract_client_ip_missing() { let headers = HeaderMap::new(); assert_eq!(extract_client_ip(&headers), None); } // ── ip_advisory_lock_key ── #[test] fn ip_advisory_lock_key_deterministic() { assert_eq!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("1.2.3.4")); } #[test] fn ip_advisory_lock_key_different_ips() { assert_ne!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("5.6.7.8")); } // ── parse_schedule_datetime ── #[test] fn parse_schedule_datetime_none_input() { assert!(parse_schedule_datetime(None).is_none()); } #[test] fn parse_schedule_datetime_empty_clears() { assert_eq!(parse_schedule_datetime(Some("")), Some(None)); } #[test] fn parse_schedule_datetime_rfc3339() { assert!(parse_schedule_datetime(Some("2026-04-29T12:00:00Z")).unwrap().is_some()); } #[test] fn parse_schedule_datetime_html_local() { assert!(parse_schedule_datetime(Some("2026-04-29T12:00")).unwrap().is_some()); } // ── stripe_timestamp ── #[test] fn stripe_timestamp_zero() { assert_eq!(stripe_timestamp(0).timestamp(), 0); } #[test] fn stripe_timestamp_valid() { assert_eq!(stripe_timestamp(1714400000).timestamp(), 1714400000); } // ── Property-based tests ── proptest::proptest! { #[test] fn prop_stripe_fee_invariant(price in 1..=1_000_000i32) { let (fee, receives) = estimate_stripe_fee(price); proptest::prop_assert_eq!(fee + receives, price, "fee ({}) + receives ({}) must equal price ({})", fee, receives, price); proptest::prop_assert!(fee > 0, "Fee should be positive for price {}", price); proptest::prop_assert!(receives >= 0, "Receives should be non-negative"); } } }