Skip to main content

max / makenotwork

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