Skip to main content

max / makenotwork

15.5 KB · 444 lines History Blame Raw
1 //! Shared utility functions used across routes and modules.
2 //!
3 //! Formatting, crypto, and rate limiting live in their own modules.
4 //! Re-exported here for backward compatibility with existing `crate::helpers::*` imports.
5
6 use axum::http::header::HeaderMap;
7 use axum::http::HeaderValue;
8 use axum::http::StatusCode;
9 use axum::response::{IntoResponse, Response};
10 use tower_sessions::Session;
11
12 use crate::AppState;
13
14 pub use crate::formatting::{
15 format_bytes, format_file_size, format_price, format_revenue,
16 get_initials, sanitize_csv_cell, slugify,
17 };
18 pub use crate::crypto::{
19 constant_time_compare, generate_feed_url, generate_key_code, verify_feed_signature,
20 };
21 pub use crate::rate_limit::{
22 rate_limiter_ms, rate_limiter_per_sec, synckit_app_rate_limiter_ms,
23 CloudflareIpKeyExtractor, SyncAppKeyExtractor,
24 };
25
26 /// Extract the client IP from request headers.
27 ///
28 /// Honors `CF-Connecting-IP` only — the single header Cloudflare sets and that
29 /// origin clients cannot reach (Hetzner firewall + Caddy strip arbitrary XFF).
30 /// `X-Forwarded-For` is intentionally not consulted: there is no trusted-proxy
31 /// allowlist, so any request bypassing Cloudflare could spoof the IP and evade
32 /// sandbox caps / poison audit logs / forge "new device" notifications.
33 ///
34 /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare
35 /// was bypassed or misconfigured — keying rate-limits on `None` then collapses
36 /// every requester into the same bucket. After 100 cumulative missing-header
37 /// requests, emit a one-shot WARN so the operator notices before any limit
38 /// surface degrades silently. Dev hits this immediately, which is fine: it's
39 /// a real signal the deployment isn't behind Cloudflare.
40 pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
41 let ip = headers
42 .get("cf-connecting-ip")
43 .and_then(|v| v.to_str().ok())
44 .and_then(|s| s.split(',').next())
45 .map(|s| s.trim().to_string())
46 .filter(|s| !s.is_empty());
47 if ip.is_none() {
48 static MISSING_COUNT: std::sync::atomic::AtomicUsize =
49 std::sync::atomic::AtomicUsize::new(0);
50 static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
51 let n = MISSING_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
52 if n >= 100 {
53 WARNED.get_or_init(|| {
54 tracing::warn!(
55 missing_count = n,
56 "cf-connecting-ip header missing on 100+ requests — rate-limits and \
57 sandbox caps will key on None. Verify Cloudflare proxy is in front \
58 of the origin (dev/test environments hit this naturally and can ignore)."
59 );
60 });
61 }
62 }
63 ip
64 }
65
66 /// Derive a stable i64 key from an IP string for use with PostgreSQL advisory locks.
67 ///
68 /// Uses SHA-256 rather than `std::collections::hash_map::DefaultHasher` —
69 /// `DefaultHasher`'s algorithm is implementation-defined and can shift between
70 /// Rust releases, which would silently change the lock keyspace on rebuild
71 /// and let two concurrent operations from the same IP grab different locks
72 /// across a deploy boundary. SHA-256 is stable forever.
73 pub fn ip_advisory_lock_key(ip: &str) -> i64 {
74 use sha2::{Digest, Sha256};
75 let mut h = Sha256::new();
76 h.update(b"sandbox_ip_cap\0");
77 h.update(ip.as_bytes());
78 let digest = h.finalize();
79 // Take the first 8 bytes as an i64 (big-endian). The full SHA-256 output
80 // is 32 bytes; the leading 8 are uniformly random over the input space.
81 i64::from_be_bytes(digest[..8].try_into().expect("sha256 yields >= 8 bytes"))
82 }
83
84 /// Check whether the incoming request was made by HTMX.
85 pub fn is_htmx_request(headers: &HeaderMap) -> bool {
86 headers.get("HX-Request").is_some()
87 }
88
89 /// Check the client's `If-None-Match` header against a cache generation.
90 /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
91 pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
92 let etag = format!("\"g{}\"", generation);
93 if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH)
94 && if_none_match.as_bytes() == etag.as_bytes()
95 {
96 return Some(
97 (
98 StatusCode::NOT_MODIFIED,
99 [(axum::http::header::ETAG, HeaderValue::try_from(&etag).unwrap_or_else(|_| HeaderValue::from_static("invalid")))],
100 )
101 .into_response(),
102 );
103 }
104 None
105 }
106
107 /// Wrap a rendered response with ETag and Cache-Control headers.
108 /// `no-cache` tells the browser to store the response but revalidate on each use.
109 pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
110 let etag = format!("\"g{}\"", generation);
111 (
112 [
113 (axum::http::header::ETAG, etag),
114 (axum::http::header::CACHE_CONTROL, "private, no-cache".to_string()),
115 ],
116 body,
117 )
118 .into_response()
119 }
120
121 /// Get or create a CSRF token for the session, returning `None` on failure.
122 ///
123 /// Convenience wrapper for templates that need an `Option<String>`.
124 pub async fn get_csrf_token(session: &Session) -> Option<String> {
125 crate::csrf::get_or_create_token(session).await.ok()
126 }
127
128 /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now.
129 pub fn stripe_timestamp(ts: i64) -> chrono::DateTime<chrono::Utc> {
130 chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now)
131 }
132
133 /// Parse an optional datetime string for scheduled publishing.
134 ///
135 /// Accepts ISO 8601 (RFC 3339) or HTML `datetime-local` format (`%Y-%m-%dT%H:%M`).
136 /// Returns `Some(Some(dt))` for a valid datetime, `Some(None)` for empty string
137 /// (clear schedule), or `None` for absent input (no change).
138 pub fn parse_schedule_datetime(s: Option<&str>) -> Option<Option<chrono::DateTime<chrono::Utc>>> {
139 s.map(|s| {
140 if s.is_empty() {
141 None
142 } else {
143 chrono::DateTime::parse_from_rfc3339(s)
144 .map(|dt| dt.with_timezone(&chrono::Utc))
145 .or_else(|_| {
146 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M")
147 .map(|naive| naive.and_utc())
148 })
149 .ok()
150 }
151 })
152 }
153
154 /// Estimate Stripe's processing fee and the net amount the creator receives.
155 ///
156 /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard
157 /// Stripe rate from [`constants`](crate::constants).
158 pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) {
159 if price_cents <= 0 {
160 return (0, 0);
161 }
162 let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE
163 + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32;
164 let creator_receives = (price_cents - fee).max(0);
165 (fee.min(price_cents), creator_receives)
166 }
167
168 /// Build an HTMX response that shows a toast notification with an empty body.
169 ///
170 /// Use for delete/action endpoints that only need to signal success via toast.
171 pub fn htmx_toast_response(
172 message: &str,
173 toast_type: &str,
174 ) -> ([(&'static str, HeaderValue); 1], axum::response::Html<String>) {
175 ([("HX-Trigger", hx_toast(message, toast_type))], axum::response::Html(String::new()))
176 }
177
178 pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
179 let json = serde_json::json!({
180 "showToast": {
181 "message": message,
182 "type": toast_type
183 }
184 })
185 .to_string();
186 HeaderValue::from_str(&json).unwrap_or_else(|e| {
187 tracing::warn!(message, error = %e, "hx_toast produced invalid header value");
188 HeaderValue::from_static("")
189 })
190 }
191
192 /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
193 /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
194 pub async fn fetch_discussion_info(
195 state: &AppState,
196 mt_thread_id: Option<crate::db::MtThreadId>,
197 project_slug: &str,
198 category_slug: &str,
199 ) -> (Option<String>, Option<i64>) {
200 let Some(thread_id) = mt_thread_id else {
201 return (None, None);
202 };
203 let Some(ref mt) = state.mt_client else {
204 return (None, None);
205 };
206 let Some(ref mt_base_url) = state.config.mt_base_url else {
207 return (None, None);
208 };
209
210 let url = format!(
211 "{}/p/{}/{}/{}",
212 mt_base_url, project_slug, category_slug, thread_id
213 );
214
215 match tokio::time::timeout(
216 std::time::Duration::from_secs(2),
217 mt.get_thread_stats(thread_id),
218 )
219 .await
220 {
221 Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
222 Ok(Err(e)) => {
223 tracing::debug!(error = ?e, "failed to fetch MT thread stats");
224 (Some(url), None)
225 }
226 Err(_) => {
227 tracing::debug!("MT thread stats request timed out");
228 (Some(url), None)
229 }
230 }
231 }
232
233 /// Fire-and-forget email send via the bounded background-task queue. The
234 /// caller passes `state` (anything that exposes `.email` and `.bg`) so the
235 /// task is bound by the global background concurrency cap rather than
236 /// `tokio::spawn`'d unbounded — Run #8 surfaced webhook bursts that could
237 /// otherwise spawn hundreds of detached email tasks competing with request
238 /// handlers for the DB pool.
239 ///
240 /// Usage:
241 /// ```ignore
242 /// spawn_email!(state, "lockout notification", |email| {
243 /// email.send_lockout_notification(&to, name.as_deref(), Some(&url))
244 /// });
245 /// ```
246 macro_rules! spawn_email {
247 ($state:expr, $context:literal, |$e:ident| $body:expr) => {{
248 let $e = $state.email.clone();
249 $state.bg.spawn($context, async move {
250 if let Err(e) = $body.await {
251 tracing::error!(error = ?e, concat!("failed to send ", $context));
252 }
253 });
254 }};
255 }
256 pub(crate) use spawn_email;
257
258 #[cfg(test)]
259 mod tests {
260 use super::*;
261
262 // ── is_htmx_request ──
263
264 #[test]
265 fn htmx_request_detected() {
266 let mut headers = HeaderMap::new();
267 headers.insert("HX-Request", HeaderValue::from_static("true"));
268 assert!(is_htmx_request(&headers));
269 }
270
271 #[test]
272 fn non_htmx_request() {
273 let headers = HeaderMap::new();
274 assert!(!is_htmx_request(&headers));
275 }
276
277 // ── hx_toast ──
278
279 #[test]
280 fn hx_toast_produces_valid_json() {
281 let val = hx_toast("Item deleted", "success");
282 let s = val.to_str().unwrap();
283 assert!(s.contains("showToast"));
284 assert!(s.contains("Item deleted"));
285 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
286 assert_eq!(parsed["showToast"]["message"], "Item deleted");
287 assert_eq!(parsed["showToast"]["type"], "success");
288 }
289
290 #[test]
291 fn hx_toast_error_type() {
292 let val = hx_toast("Something failed", "error");
293 let s = val.to_str().unwrap();
294 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
295 assert_eq!(parsed["showToast"]["type"], "error");
296 }
297
298 #[test]
299 fn hx_toast_with_quotes() {
300 let val = hx_toast("Say \"hello\"", "info");
301 let s = val.to_str().unwrap();
302 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
303 assert_eq!(parsed["showToast"]["message"], "Say \"hello\"");
304 }
305
306 #[test]
307 fn adversarial_hx_toast_json_injection() {
308 let val = hx_toast("\"},{\"malicious\":\"true", "error");
309 let s = val.to_str().unwrap();
310 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
311 assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true");
312 }
313
314 // ── estimate_stripe_fee ──
315
316 #[test]
317 fn stripe_fee_standard_price() {
318 let (fee, receives) = estimate_stripe_fee(1000);
319 assert_eq!(fee, 59);
320 assert_eq!(receives, 941);
321 }
322
323 #[test]
324 fn stripe_fee_small_price() {
325 let (fee, receives) = estimate_stripe_fee(100);
326 assert_eq!(fee, 32);
327 assert_eq!(receives, 68);
328 }
329
330 #[test]
331 fn stripe_fee_zero_is_free() {
332 let (fee, receives) = estimate_stripe_fee(0);
333 assert_eq!(fee, 0);
334 assert_eq!(receives, 0);
335 }
336
337 #[test]
338 fn stripe_fee_negative_price() {
339 let (fee, receives) = estimate_stripe_fee(-100);
340 assert_eq!(fee, 0);
341 assert_eq!(receives, 0);
342 }
343
344 #[test]
345 fn stripe_fee_plus_receives_equals_price() {
346 for price in [50, 100, 250, 500, 999, 1000, 2500, 5000, 10000, 50000] {
347 let (fee, receives) = estimate_stripe_fee(price);
348 assert_eq!(fee + receives, price, "fee + receives should equal price for {} cents", price);
349 }
350 }
351
352 // ── extract_client_ip ──
353
354 #[test]
355 fn extract_client_ip_cf_preferred() {
356 let mut headers = HeaderMap::new();
357 headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
358 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
359 assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
360 }
361
362 #[test]
363 fn extract_client_ip_ignores_xff_when_cf_missing() {
364 // XFF alone must not be trusted — see security note on extract_client_ip.
365 let mut headers = HeaderMap::new();
366 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8, 9.10.11.12"));
367 assert_eq!(extract_client_ip(&headers), None);
368 }
369
370 #[test]
371 fn extract_client_ip_ignores_xff_even_when_cf_present() {
372 // Defense in depth: presence of XFF must not influence the result.
373 let mut headers = HeaderMap::new();
374 headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
375 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
376 assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
377 }
378
379 #[test]
380 fn extract_client_ip_missing() {
381 let headers = HeaderMap::new();
382 assert_eq!(extract_client_ip(&headers), None);
383 }
384
385 // ── ip_advisory_lock_key ──
386
387 #[test]
388 fn ip_advisory_lock_key_deterministic() {
389 assert_eq!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("1.2.3.4"));
390 }
391
392 #[test]
393 fn ip_advisory_lock_key_different_ips() {
394 assert_ne!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("5.6.7.8"));
395 }
396
397 // ── parse_schedule_datetime ──
398
399 #[test]
400 fn parse_schedule_datetime_none_input() {
401 assert!(parse_schedule_datetime(None).is_none());
402 }
403
404 #[test]
405 fn parse_schedule_datetime_empty_clears() {
406 assert_eq!(parse_schedule_datetime(Some("")), Some(None));
407 }
408
409 #[test]
410 fn parse_schedule_datetime_rfc3339() {
411 assert!(parse_schedule_datetime(Some("2026-04-29T12:00:00Z")).unwrap().is_some());
412 }
413
414 #[test]
415 fn parse_schedule_datetime_html_local() {
416 assert!(parse_schedule_datetime(Some("2026-04-29T12:00")).unwrap().is_some());
417 }
418
419 // ── stripe_timestamp ──
420
421 #[test]
422 fn stripe_timestamp_zero() {
423 assert_eq!(stripe_timestamp(0).timestamp(), 0);
424 }
425
426 #[test]
427 fn stripe_timestamp_valid() {
428 assert_eq!(stripe_timestamp(1714400000).timestamp(), 1714400000);
429 }
430
431 // ── Property-based tests ──
432
433 proptest::proptest! {
434 #[test]
435 fn prop_stripe_fee_invariant(price in 1..=1_000_000i32) {
436 let (fee, receives) = estimate_stripe_fee(price);
437 proptest::prop_assert_eq!(fee + receives, price,
438 "fee ({}) + receives ({}) must equal price ({})", fee, receives, price);
439 proptest::prop_assert!(fee > 0, "Fee should be positive for price {}", price);
440 proptest::prop_assert!(receives >= 0, "Receives should be non-negative");
441 }
442 }
443 }
444