Skip to main content

max / makenotwork

audit Run 14 Phase 2: split helpers.rs grab-bag into focused submodules helpers.rs (549 lines, ~10 unrelated concerns) -> helpers/ directory: - http.rs HTTP_CLIENT, extract_client_ip, ip_advisory_lock_key, is_htmx_request, check_etag, with_etag, hx_toast, htmx_toast_response (+ tests) - db.rs is_unique_violation, map_unique_violation, insert_with_unique_slug, SLUG_SUFFIX_CAP - render.rs render_fragment, escape_html - billing.rs estimate_stripe_fee, stripe_timestamp (+ tests) - datetime.rs parse_schedule_datetime (+ tests) - integration.rs get_csrf_token, fetch_discussion_info mod.rs re-exports everything flat (+ formatting/crypto/rate_limit re-exports and the spawn_email! macro), so every crate::helpers::* call site is unchanged. check_etag/with_etag kept (13 live call sites — the audit's dead-code note was a false positive). No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 22:49 UTC
Commit: 2d06790eb5c7424891f62240a70a85ead6fe360e
Parent: 7c69fa4
8 files changed, +595 insertions, -500 deletions
@@ -1,549 +0,0 @@
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 - /// Shared outbound HTTP client. `reqwest::Client` is internally `Arc`-wrapped
27 - /// and pools connections, so it is meant to be built once and reused; a
28 - /// per-request `reqwest::Client::new()` throws away the connection pool and
29 - /// re-runs TLS setup every call. Carries a 10s default timeout as a backstop —
30 - /// call sites may still set a tighter per-request `.timeout()`, which wins.
31 - /// (Clients that need bespoke config — Postmark, PoM probe — keep their own.)
32 - pub static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
33 - reqwest::Client::builder()
34 - .timeout(std::time::Duration::from_secs(10))
35 - .build()
36 - .expect("build shared reqwest client")
37 - });
38 -
39 - /// Maximum auto-suffix attempts when allocating a unique slug before giving up.
40 - pub const SLUG_SUFFIX_CAP: u32 = 100;
41 -
42 - /// Whether `e` is a Postgres unique-violation (SQLSTATE 23505) — the slug
43 - /// collision backstop every [`insert_with_unique_slug`] caller relies on.
44 - pub fn is_unique_violation(e: &crate::error::AppError) -> bool {
45 - matches!(
46 - e,
47 - crate::error::AppError::Database(sqlx::Error::Database(db_err))
48 - if db_err.code().as_deref() == Some("23505")
49 - )
50 - }
51 -
52 - /// Render an Askama fragment to an HTML string, surfacing a render failure as an
53 - /// `AppError` (mapped to 500 + an `HX-Error` header by the error responder) instead
54 - /// of the silent `.render().unwrap_or_default()` blank swap — which installs an
55 - /// empty HTMX fragment with no error signal (ultra-fuzz Run 12 doubledown S-A). Use
56 - /// this for HTMX fragment handlers that return `Result`.
57 - pub fn render_fragment(template: &impl askama::Template) -> crate::error::Result<String> {
58 - template.render().map_err(|e| {
59 - tracing::error!(error = ?e, "fragment render failed");
60 - crate::error::AppError::Internal(anyhow::anyhow!("template render failed"))
61 - })
62 - }
63 -
64 - /// Map a Postgres unique-violation (23505) into a clean `AppError::Conflict(msg)`;
65 - /// pass any other error through unchanged. A create/insert on a deterministic or
66 - /// user-supplied unique key that legitimately re-runs (a retry, a duplicate the
67 - /// user can fix) should surface a 409 with a helpful message, not a raw 500
68 - /// (ultra-fuzz Run 12 Storage: unhandled 23505 on lower-traffic create paths).
69 - /// Use only where the INSERT has a single relevant unique constraint.
70 - pub fn map_unique_violation(e: crate::error::AppError, msg: &str) -> crate::error::AppError {
71 - if is_unique_violation(&e) {
72 - crate::error::AppError::Conflict(msg.to_string())
73 - } else {
74 - e
75 - }
76 - }
77 -
78 - /// Insert a row whose slug must be unique under a per-parent `UNIQUE` index,
79 - /// auto-suffixing the slug (`base`, `base-2`, `base-3`, ...) on collision.
80 - ///
81 - /// The `UNIQUE` index is the race-safe source of truth: `insert` is retried on a
82 - /// 23505 unique violation, up to [`SLUG_SUFFIX_CAP`] attempts, so a concurrent
83 - /// insert racing between any pre-check and this one can never surface a raw 500.
84 - /// `insert` receives the candidate slug and performs the actual row insert.
85 - /// Collapses the formerly copy-pasted suffix loops across blog posts, items, and
86 - /// item/project sections (ultra-fuzz Run #1 UX, D1) — including the internal
87 - /// content route that previously pre-checked but did not retry the insert.
88 - pub async fn insert_with_unique_slug<T, F, Fut>(
89 - base: &str,
90 - mut insert: F,
91 - ) -> crate::error::Result<T>
92 - where
93 - F: FnMut(String) -> Fut,
94 - Fut: std::future::Future<Output = crate::error::Result<T>>,
95 - {
96 - let mut slug = base.to_string();
97 - let mut suffix = 1u32;
98 - loop {
99 - match insert(slug.clone()).await {
100 - Ok(value) => return Ok(value),
101 - Err(e) if is_unique_violation(&e) && suffix < SLUG_SUFFIX_CAP => {
102 - suffix += 1;
103 - slug = format!("{base}-{suffix}");
104 - }
105 - Err(e) => return Err(e),
106 - }
107 - }
108 - }
109 -
110 - /// Escape HTML special characters, including `'` so the output is safe in
111 - /// single-quoted attribute contexts as well as element text. Use whenever a
112 - /// value is interpolated into a raw `format!`-built HTML string instead of an
113 - /// auto-escaped template.
114 - pub fn escape_html(s: &str) -> String {
115 - s.replace('&', "&amp;")
116 - .replace('<', "&lt;")
117 - .replace('>', "&gt;")
118 - .replace('"', "&quot;")
119 - .replace('\'', "&#39;")
120 - }
121 -
122 - /// Extract the client IP from request headers.
123 - ///
124 - /// Honors `CF-Connecting-IP` only — and that header is trustworthy on every
125 - /// public path: the makenot.work blocks enforce Cloudflare mTLS (only
126 - /// Cloudflare reaches the origin, and it sets the header), and the custom-domain
127 - /// `:443` block overwrites `CF-Connecting-IP` with the real TCP peer + strips
128 - /// `X-Forwarded-For` before proxying (see `deploy/Caddyfile`). `X-Forwarded-For`
129 - /// is intentionally never consulted: there is no trusted-proxy allowlist, so a
130 - /// request reaching the app with a client-set XFF could spoof the IP and evade
131 - /// sandbox caps / poison audit logs / forge "new device" notifications.
132 - ///
133 - /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare
134 - /// was bypassed or misconfigured — keying rate-limits on `None` then collapses
135 - /// every requester into the same bucket. After 100 cumulative missing-header
136 - /// requests, emit a one-shot WARN so the operator notices before any limit
137 - /// surface degrades silently. Dev hits this immediately, which is fine: it's
138 - /// a real signal the deployment isn't behind Cloudflare.
139 - pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
140 - let ip = headers
141 - .get("cf-connecting-ip")
142 - .and_then(|v| v.to_str().ok())
143 - .and_then(|s| s.split(',').next())
144 - .map(|s| s.trim().to_string())
145 - .filter(|s| !s.is_empty());
146 - if ip.is_none() {
147 - static MISSING_COUNT: std::sync::atomic::AtomicUsize =
148 - std::sync::atomic::AtomicUsize::new(0);
149 - static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
150 - let n = MISSING_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
151 - if n >= 100 {
152 - WARNED.get_or_init(|| {
153 - tracing::warn!(
154 - missing_count = n,
155 - "cf-connecting-ip header missing on 100+ requests — rate-limits and \
156 - sandbox caps will key on None. Verify Cloudflare proxy is in front \
157 - of the origin (dev/test environments hit this naturally and can ignore)."
158 - );
159 - });
160 - }
161 - }
162 - ip
163 - }
164 -
165 - /// Derive a stable i64 key from an IP string for use with PostgreSQL advisory locks.
166 - ///
167 - /// Uses SHA-256 rather than `std::collections::hash_map::DefaultHasher` —
168 - /// `DefaultHasher`'s algorithm is implementation-defined and can shift between
169 - /// Rust releases, which would silently change the lock keyspace on rebuild
170 - /// and let two concurrent operations from the same IP grab different locks
171 - /// across a deploy boundary. SHA-256 is stable forever.
172 - pub fn ip_advisory_lock_key(ip: &str) -> i64 {
173 - use sha2::{Digest, Sha256};
174 - let mut h = Sha256::new();
175 - h.update(b"sandbox_ip_cap\0");
176 - h.update(ip.as_bytes());
177 - let digest = h.finalize();
178 - // Take the first 8 bytes as an i64 (big-endian). The full SHA-256 output
179 - // is 32 bytes; the leading 8 are uniformly random over the input space.
180 - i64::from_be_bytes(digest[..8].try_into().expect("sha256 yields >= 8 bytes"))
181 - }
182 -
183 - /// Check whether the incoming request was made by HTMX.
184 - pub fn is_htmx_request(headers: &HeaderMap) -> bool {
185 - headers.get("HX-Request").is_some()
186 - }
187 -
188 - /// Check the client's `If-None-Match` header against a cache generation.
189 - /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
190 - pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
191 - let etag = format!("\"g{}\"", generation);
192 - if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH)
193 - && if_none_match.as_bytes() == etag.as_bytes()
194 - {
195 - return Some(
196 - (
197 - StatusCode::NOT_MODIFIED,
198 - [(axum::http::header::ETAG, HeaderValue::try_from(&etag).unwrap_or_else(|_| HeaderValue::from_static("invalid")))],
199 - )
200 - .into_response(),
201 - );
202 - }
203 - None
204 - }
205 -
206 - /// Wrap a rendered response with ETag and Cache-Control headers.
207 - /// `no-cache` tells the browser to store the response but revalidate on each use.
208 - pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
209 - let etag = format!("\"g{}\"", generation);
210 - (
211 - [
212 - (axum::http::header::ETAG, etag),
213 - (axum::http::header::CACHE_CONTROL, "private, no-cache".to_string()),
214 - ],
215 - body,
216 - )
217 - .into_response()
218 - }
219 -
220 - /// Get or create a CSRF token for the session, returning `None` on failure.
221 - ///
222 - /// Convenience wrapper for templates that need an `Option<String>`.
223 - pub async fn get_csrf_token(session: &Session) -> Option<String> {
224 - crate::csrf::get_or_create_token(session).await.ok()
225 - }
226 -
227 - /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now.
228 - pub fn stripe_timestamp(ts: i64) -> chrono::DateTime<chrono::Utc> {
229 - chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now)
230 - }
231 -
232 - /// Parse an optional datetime string for scheduled publishing.
233 - ///
234 - /// Accepts ISO 8601 (RFC 3339) or HTML `datetime-local` format (`%Y-%m-%dT%H:%M`).
235 - /// Returns `Some(Some(dt))` for a valid datetime, `Some(None)` for empty string
236 - /// (clear schedule), or `None` for absent input (no change).
237 - pub fn parse_schedule_datetime(s: Option<&str>) -> Option<Option<chrono::DateTime<chrono::Utc>>> {
238 - s.map(|s| {
239 - if s.is_empty() {
240 - None
241 - } else {
242 - chrono::DateTime::parse_from_rfc3339(s)
243 - .map(|dt| dt.with_timezone(&chrono::Utc))
244 - .or_else(|_| {
245 - chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M")
246 - .map(|naive| naive.and_utc())
247 - })
248 - .ok()
249 - }
250 - })
251 - }
252 -
253 - /// Estimate Stripe's processing fee and the net amount the creator receives.
254 - ///
255 - /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard
256 - /// Stripe rate from [`constants`](crate::constants).
257 - pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) {
258 - if price_cents <= 0 {
259 - return (0, 0);
260 - }
261 - let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE
262 - + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32;
263 - let creator_receives = (price_cents - fee).max(0);
264 - (fee.min(price_cents), creator_receives)
265 - }
266 -
267 - /// Build an HTMX response that shows a toast notification with an empty body.
268 - ///
269 - /// Use for delete/action endpoints that only need to signal success via toast.
270 - pub fn htmx_toast_response(
271 - message: &str,
272 - toast_type: &str,
273 - ) -> ([(&'static str, HeaderValue); 1], axum::response::Html<String>) {
274 - ([("HX-Trigger", hx_toast(message, toast_type))], axum::response::Html(String::new()))
275 - }
276 -
277 - pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
278 - // Strip control characters (C0, DEL/0x7F, C1) before encoding. serde_json
279 - // escapes the < 0x20 controls, but DEL and the C1 range pass through raw and
280 - // make `HeaderValue::from_str` reject the whole header — which silently drops
281 - // the toast. Stripping them keeps the JSON header-safe; the fallback below
282 - // stays as defense-in-depth.
283 - let message: String = message.chars().filter(|c| !c.is_control()).collect();
284 - let toast_type: String = toast_type.chars().filter(|c| !c.is_control()).collect();
285 - let json = serde_json::json!({
286 - "showToast": {
287 - "message": message,
288 - "type": toast_type
289 - }
290 - })
291 - .to_string();
292 - HeaderValue::from_str(&json).unwrap_or_else(|e| {
293 - tracing::warn!(error = %e, "hx_toast produced invalid header value");
294 - HeaderValue::from_static("")
295 - })
296 - }
297 -
298 - /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
299 - /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
300 - pub async fn fetch_discussion_info(
301 - state: &AppState,
302 - mt_thread_id: Option<crate::db::MtThreadId>,
303 - project_slug: &str,
304 - category_slug: &str,
305 - ) -> (Option<String>, Option<i64>) {
306 - let Some(thread_id) = mt_thread_id else {
307 - return (None, None);
308 - };
309 - let Some(ref mt) = state.mt_client else {
310 - return (None, None);
311 - };
312 - let Some(ref mt_base_url) = state.config.mt_base_url else {
313 - return (None, None);
314 - };
315 -
316 - let url = format!(
317 - "{}/p/{}/{}/{}",
318 - mt_base_url, project_slug, category_slug, thread_id
319 - );
320 -
321 - match tokio::time::timeout(
322 - std::time::Duration::from_secs(2),
323 - mt.get_thread_stats(thread_id),
324 - )
325 - .await
326 - {
327 - Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
328 - Ok(Err(e)) => {
329 - tracing::debug!(error = ?e, "failed to fetch MT thread stats");
330 - (Some(url), None)
331 - }
332 - Err(_) => {
333 - tracing::debug!("MT thread stats request timed out");
334 - (Some(url), None)
335 - }
336 - }
337 - }
338 -
339 - /// Fire-and-forget email send via the bounded background-task queue. The
340 - /// caller passes `state` (anything that exposes `.email` and `.bg`) so the
341 - /// task is bound by the global background concurrency cap rather than
342 - /// `tokio::spawn`'d unbounded — Run #8 surfaced webhook bursts that could
343 - /// otherwise spawn hundreds of detached email tasks competing with request
344 - /// handlers for the DB pool.
345 - ///
346 - /// Usage:
347 - /// ```ignore
348 - /// spawn_email!(state, "lockout notification", |email| {
349 - /// email.send_lockout_notification(&to, name.as_deref(), Some(&url))
350 - /// });
351 - /// ```
352 - macro_rules! spawn_email {
353 - ($state:expr, $context:literal, |$e:ident| $body:expr) => {{
354 - let $e = $state.email.clone();
355 - $state.bg.spawn($context, async move {
356 - if let Err(e) = $body.await {
357 - tracing::error!(error = ?e, concat!("failed to send ", $context));
358 - }
359 - });
360 - }};
361 - }
362 - pub(crate) use spawn_email;
363 -
364 - #[cfg(test)]
365 - mod tests {
366 - use super::*;
367 -
368 - // ── is_htmx_request ──
369 -
370 - #[test]
371 - fn htmx_request_detected() {
372 - let mut headers = HeaderMap::new();
373 - headers.insert("HX-Request", HeaderValue::from_static("true"));
374 - assert!(is_htmx_request(&headers));
375 - }
376 -
377 - #[test]
378 - fn non_htmx_request() {
379 - let headers = HeaderMap::new();
380 - assert!(!is_htmx_request(&headers));
381 - }
382 -
383 - // ── hx_toast ──
384 -
385 - #[test]
386 - fn hx_toast_produces_valid_json() {
387 - let val = hx_toast("Item deleted", "success");
388 - let s = val.to_str().unwrap();
389 - assert!(s.contains("showToast"));
390 - assert!(s.contains("Item deleted"));
391 - let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
392 - assert_eq!(parsed["showToast"]["message"], "Item deleted");
393 - assert_eq!(parsed["showToast"]["type"], "success");
394 - }
395 -
396 - #[test]
397 - fn hx_toast_error_type() {
398 - let val = hx_toast("Something failed", "error");
399 - let s = val.to_str().unwrap();
400 - let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
401 - assert_eq!(parsed["showToast"]["type"], "error");
402 - }
403 -
404 - #[test]
405 - fn hx_toast_with_quotes() {
406 - let val = hx_toast("Say \"hello\"", "info");
407 - let s = val.to_str().unwrap();
408 - let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
409 - assert_eq!(parsed["showToast"]["message"], "Say \"hello\"");
410 - }
411 -
412 - #[test]
413 - fn adversarial_hx_toast_json_injection() {
414 - let val = hx_toast("\"},{\"malicious\":\"true", "error");
415 - let s = val.to_str().unwrap();
416 - let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
417 - assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true");
418 - }
419 -
420 - // ── estimate_stripe_fee ──
421 -
422 - #[test]
423 - fn stripe_fee_standard_price() {
424 - let (fee, receives) = estimate_stripe_fee(1000);
425 - assert_eq!(fee, 59);
426 - assert_eq!(receives, 941);
427 - }
428 -
429 - #[test]
430 - fn stripe_fee_small_price() {
431 - let (fee, receives) = estimate_stripe_fee(100);
432 - assert_eq!(fee, 32);
433 - assert_eq!(receives, 68);
434 - }
435 -
436 - #[test]
437 - fn stripe_fee_zero_is_free() {
438 - let (fee, receives) = estimate_stripe_fee(0);
439 - assert_eq!(fee, 0);
440 - assert_eq!(receives, 0);
441 - }
442 -
443 - #[test]
444 - fn stripe_fee_negative_price() {
445 - let (fee, receives) = estimate_stripe_fee(-100);
446 - assert_eq!(fee, 0);
447 - assert_eq!(receives, 0);
448 - }
449 -
450 - #[test]
451 - fn stripe_fee_plus_receives_equals_price() {
452 - for price in [50, 100, 250, 500, 999, 1000, 2500, 5000, 10000, 50000] {
453 - let (fee, receives) = estimate_stripe_fee(price);
454 - assert_eq!(fee + receives, price, "fee + receives should equal price for {} cents", price);
455 - }
456 - }
457 -
458 - // ── extract_client_ip ──
459 -
460 - #[test]
461 - fn extract_client_ip_cf_preferred() {
462 - let mut headers = HeaderMap::new();
463 - headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
464 - headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
465 - assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
466 - }
467 -
468 - #[test]
469 - fn extract_client_ip_ignores_xff_when_cf_missing() {
470 - // XFF alone must not be trusted — see security note on extract_client_ip.
471 - let mut headers = HeaderMap::new();
472 - headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8, 9.10.11.12"));
473 - assert_eq!(extract_client_ip(&headers), None);
474 - }
475 -
476 - #[test]
477 - fn extract_client_ip_ignores_xff_even_when_cf_present() {
478 - // Defense in depth: presence of XFF must not influence the result.
479 - let mut headers = HeaderMap::new();
480 - headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
481 - headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
482 - assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
483 - }
484 -
485 - #[test]
486 - fn extract_client_ip_missing() {
487 - let headers = HeaderMap::new();
488 - assert_eq!(extract_client_ip(&headers), None);
489 - }
490 -
491 - // ── ip_advisory_lock_key ──
492 -
493 - #[test]
494 - fn ip_advisory_lock_key_deterministic() {
495 - assert_eq!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("1.2.3.4"));
496 - }
497 -
498 - #[test]
499 - fn ip_advisory_lock_key_different_ips() {
500 - assert_ne!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("5.6.7.8"));
Lines truncated
@@ -0,0 +1,88 @@
1 + //! Stripe billing helpers: fee estimation and timestamp conversion.
2 +
3 + /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now.
4 + pub fn stripe_timestamp(ts: i64) -> chrono::DateTime<chrono::Utc> {
5 + chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now)
6 + }
7 +
8 + /// Estimate Stripe's processing fee and the net amount the creator receives.
9 + ///
10 + /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard
11 + /// Stripe rate from [`constants`](crate::constants).
12 + pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) {
13 + if price_cents <= 0 {
14 + return (0, 0);
15 + }
16 + let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE
17 + + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32;
18 + let creator_receives = (price_cents - fee).max(0);
19 + (fee.min(price_cents), creator_receives)
20 + }
21 +
22 + #[cfg(test)]
23 + mod tests {
24 + use super::*;
25 +
26 + // ── estimate_stripe_fee ──
27 +
28 + #[test]
29 + fn stripe_fee_standard_price() {
30 + let (fee, receives) = estimate_stripe_fee(1000);
31 + assert_eq!(fee, 59);
32 + assert_eq!(receives, 941);
33 + }
34 +
35 + #[test]
36 + fn stripe_fee_small_price() {
37 + let (fee, receives) = estimate_stripe_fee(100);
38 + assert_eq!(fee, 32);
39 + assert_eq!(receives, 68);
40 + }
41 +
42 + #[test]
43 + fn stripe_fee_zero_is_free() {
44 + let (fee, receives) = estimate_stripe_fee(0);
45 + assert_eq!(fee, 0);
46 + assert_eq!(receives, 0);
47 + }
48 +
49 + #[test]
50 + fn stripe_fee_negative_price() {
51 + let (fee, receives) = estimate_stripe_fee(-100);
52 + assert_eq!(fee, 0);
53 + assert_eq!(receives, 0);
54 + }
55 +
56 + #[test]
57 + fn stripe_fee_plus_receives_equals_price() {
58 + for price in [50, 100, 250, 500, 999, 1000, 2500, 5000, 10000, 50000] {
59 + let (fee, receives) = estimate_stripe_fee(price);
60 + assert_eq!(fee + receives, price, "fee + receives should equal price for {} cents", price);
61 + }
62 + }
63 +
64 + // ── stripe_timestamp ──
65 +
66 + #[test]
67 + fn stripe_timestamp_zero() {
68 + assert_eq!(stripe_timestamp(0).timestamp(), 0);
69 + }
70 +
71 + #[test]
72 + fn stripe_timestamp_valid() {
73 + assert_eq!(stripe_timestamp(1714400000).timestamp(), 1714400000);
74 + }
75 +
76 + // ── Property-based tests ──
77 +
78 + proptest::proptest! {
79 + #[test]
80 + fn prop_stripe_fee_invariant(price in 1..=1_000_000i32) {
81 + let (fee, receives) = estimate_stripe_fee(price);
82 + proptest::prop_assert_eq!(fee + receives, price,
83 + "fee ({}) + receives ({}) must equal price ({})", fee, receives, price);
84 + proptest::prop_assert!(fee > 0, "Fee should be positive for price {}", price);
85 + proptest::prop_assert!(receives >= 0, "Receives should be non-negative");
86 + }
87 + }
88 + }
@@ -0,0 +1,47 @@
1 + //! Date/time parsing helpers for user-supplied schedule inputs.
2 +
3 + /// Parse an optional datetime string for scheduled publishing.
4 + ///
5 + /// Accepts ISO 8601 (RFC 3339) or HTML `datetime-local` format (`%Y-%m-%dT%H:%M`).
6 + /// Returns `Some(Some(dt))` for a valid datetime, `Some(None)` for empty string
7 + /// (clear schedule), or `None` for absent input (no change).
8 + pub fn parse_schedule_datetime(s: Option<&str>) -> Option<Option<chrono::DateTime<chrono::Utc>>> {
9 + s.map(|s| {
10 + if s.is_empty() {
11 + None
12 + } else {
13 + chrono::DateTime::parse_from_rfc3339(s)
14 + .map(|dt| dt.with_timezone(&chrono::Utc))
15 + .or_else(|_| {
16 + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M")
17 + .map(|naive| naive.and_utc())
18 + })
19 + .ok()
20 + }
21 + })
22 + }
23 +
24 + #[cfg(test)]
25 + mod tests {
26 + use super::*;
27 +
28 + #[test]
29 + fn parse_schedule_datetime_none_input() {
30 + assert!(parse_schedule_datetime(None).is_none());
31 + }
32 +
33 + #[test]
34 + fn parse_schedule_datetime_empty_clears() {
35 + assert_eq!(parse_schedule_datetime(Some("")), Some(None));
36 + }
37 +
38 + #[test]
39 + fn parse_schedule_datetime_rfc3339() {
40 + assert!(parse_schedule_datetime(Some("2026-04-29T12:00:00Z")).unwrap().is_some());
41 + }
42 +
43 + #[test]
44 + fn parse_schedule_datetime_html_local() {
45 + assert!(parse_schedule_datetime(Some("2026-04-29T12:00")).unwrap().is_some());
46 + }
47 + }
@@ -0,0 +1,61 @@
1 + //! Database-error helpers: unique-violation (23505) detection/mapping and the
2 + //! auto-suffixing unique-slug insert loop.
3 +
4 + /// Maximum auto-suffix attempts when allocating a unique slug before giving up.
5 + pub const SLUG_SUFFIX_CAP: u32 = 100;
6 +
7 + /// Whether `e` is a Postgres unique-violation (SQLSTATE 23505) — the slug
8 + /// collision backstop every [`insert_with_unique_slug`] caller relies on.
9 + pub fn is_unique_violation(e: &crate::error::AppError) -> bool {
10 + matches!(
11 + e,
12 + crate::error::AppError::Database(sqlx::Error::Database(db_err))
13 + if db_err.code().as_deref() == Some("23505")
14 + )
15 + }
16 +
17 + /// Map a Postgres unique-violation (23505) into a clean `AppError::Conflict(msg)`;
18 + /// pass any other error through unchanged. A create/insert on a deterministic or
19 + /// user-supplied unique key that legitimately re-runs (a retry, a duplicate the
20 + /// user can fix) should surface a 409 with a helpful message, not a raw 500
21 + /// (ultra-fuzz Run 12 Storage: unhandled 23505 on lower-traffic create paths).
22 + /// Use only where the INSERT has a single relevant unique constraint.
23 + pub fn map_unique_violation(e: crate::error::AppError, msg: &str) -> crate::error::AppError {
24 + if is_unique_violation(&e) {
25 + crate::error::AppError::Conflict(msg.to_string())
26 + } else {
27 + e
28 + }
29 + }
30 +
31 + /// Insert a row whose slug must be unique under a per-parent `UNIQUE` index,
32 + /// auto-suffixing the slug (`base`, `base-2`, `base-3`, ...) on collision.
33 + ///
34 + /// The `UNIQUE` index is the race-safe source of truth: `insert` is retried on a
35 + /// 23505 unique violation, up to [`SLUG_SUFFIX_CAP`] attempts, so a concurrent
36 + /// insert racing between any pre-check and this one can never surface a raw 500.
37 + /// `insert` receives the candidate slug and performs the actual row insert.
38 + /// Collapses the formerly copy-pasted suffix loops across blog posts, items, and
39 + /// item/project sections (ultra-fuzz Run #1 UX, D1) — including the internal
40 + /// content route that previously pre-checked but did not retry the insert.
41 + pub async fn insert_with_unique_slug<T, F, Fut>(
42 + base: &str,
43 + mut insert: F,
44 + ) -> crate::error::Result<T>
45 + where
46 + F: FnMut(String) -> Fut,
47 + Fut: std::future::Future<Output = crate::error::Result<T>>,
48 + {
49 + let mut slug = base.to_string();
50 + let mut suffix = 1u32;
51 + loop {
52 + match insert(slug.clone()).await {
53 + Ok(value) => return Ok(value),
54 + Err(e) if is_unique_violation(&e) && suffix < SLUG_SUFFIX_CAP => {
55 + suffix += 1;
56 + slug = format!("{base}-{suffix}");
57 + }
58 + Err(e) => return Err(e),
59 + }
60 + }
61 + }
@@ -0,0 +1,251 @@
1 + //! HTTP request/response helpers: the shared outbound client, client-IP
2 + //! extraction, HTMX detection, ETag conditional responses, and toast headers.
3 +
4 + use axum::http::header::HeaderMap;
5 + use axum::http::HeaderValue;
6 + use axum::http::StatusCode;
7 + use axum::response::{IntoResponse, Response};
8 +
9 + /// Shared outbound HTTP client. `reqwest::Client` is internally `Arc`-wrapped
10 + /// and pools connections, so it is meant to be built once and reused; a
11 + /// per-request `reqwest::Client::new()` throws away the connection pool and
12 + /// re-runs TLS setup every call. Carries a 10s default timeout as a backstop —
13 + /// call sites may still set a tighter per-request `.timeout()`, which wins.
14 + /// (Clients that need bespoke config — Postmark, PoM probe — keep their own.)
15 + pub static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
16 + reqwest::Client::builder()
17 + .timeout(std::time::Duration::from_secs(10))
18 + .build()
19 + .expect("build shared reqwest client")
20 + });
21 +
22 + /// Extract the client IP from request headers.
23 + ///
24 + /// Honors `CF-Connecting-IP` only — and that header is trustworthy on every
25 + /// public path: the makenot.work blocks enforce Cloudflare mTLS (only
26 + /// Cloudflare reaches the origin, and it sets the header), and the custom-domain
27 + /// `:443` block overwrites `CF-Connecting-IP` with the real TCP peer + strips
28 + /// `X-Forwarded-For` before proxying (see `deploy/Caddyfile`). `X-Forwarded-For`
29 + /// is intentionally never consulted: there is no trusted-proxy allowlist, so a
30 + /// request reaching the app with a client-set XFF could spoof the IP and evade
31 + /// sandbox caps / poison audit logs / forge "new device" notifications.
32 + ///
33 + /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare
34 + /// was bypassed or misconfigured — keying rate-limits on `None` then collapses
35 + /// every requester into the same bucket. After 100 cumulative missing-header
36 + /// requests, emit a one-shot WARN so the operator notices before any limit
37 + /// surface degrades silently. Dev hits this immediately, which is fine: it's
38 + /// a real signal the deployment isn't behind Cloudflare.
39 + pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
40 + let ip = headers
41 + .get("cf-connecting-ip")
42 + .and_then(|v| v.to_str().ok())
43 + .and_then(|s| s.split(',').next())
44 + .map(|s| s.trim().to_string())
45 + .filter(|s| !s.is_empty());
46 + if ip.is_none() {
47 + static MISSING_COUNT: std::sync::atomic::AtomicUsize =
48 + std::sync::atomic::AtomicUsize::new(0);
49 + static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
50 + let n = MISSING_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
51 + if n >= 100 {
52 + WARNED.get_or_init(|| {
53 + tracing::warn!(
54 + missing_count = n,
55 + "cf-connecting-ip header missing on 100+ requests — rate-limits and \
56 + sandbox caps will key on None. Verify Cloudflare proxy is in front \
57 + of the origin (dev/test environments hit this naturally and can ignore)."
58 + );
59 + });
60 + }
61 + }
62 + ip
63 + }
64 +
65 + /// Derive a stable i64 key from an IP string for use with PostgreSQL advisory locks.
66 + ///
67 + /// Uses SHA-256 rather than `std::collections::hash_map::DefaultHasher` —
68 + /// `DefaultHasher`'s algorithm is implementation-defined and can shift between
69 + /// Rust releases, which would silently change the lock keyspace on rebuild
70 + /// and let two concurrent operations from the same IP grab different locks
71 + /// across a deploy boundary. SHA-256 is stable forever.
72 + pub fn ip_advisory_lock_key(ip: &str) -> i64 {
73 + use sha2::{Digest, Sha256};
74 + let mut h = Sha256::new();
75 + h.update(b"sandbox_ip_cap\0");
76 + h.update(ip.as_bytes());
77 + let digest = h.finalize();
78 + // Take the first 8 bytes as an i64 (big-endian). The full SHA-256 output
79 + // is 32 bytes; the leading 8 are uniformly random over the input space.
80 + i64::from_be_bytes(digest[..8].try_into().expect("sha256 yields >= 8 bytes"))
81 + }
82 +
83 + /// Check whether the incoming request was made by HTMX.
84 + pub fn is_htmx_request(headers: &HeaderMap) -> bool {
85 + headers.get("HX-Request").is_some()
86 + }
87 +
88 + /// Check the client's `If-None-Match` header against a cache generation.
89 + /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
90 + pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
91 + let etag = format!("\"g{}\"", generation);
92 + if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH)
93 + && if_none_match.as_bytes() == etag.as_bytes()
94 + {
95 + return Some(
96 + (
97 + StatusCode::NOT_MODIFIED,
98 + [(axum::http::header::ETAG, HeaderValue::try_from(&etag).unwrap_or_else(|_| HeaderValue::from_static("invalid")))],
99 + )
100 + .into_response(),
101 + );
102 + }
103 + None
104 + }
105 +
106 + /// Wrap a rendered response with ETag and Cache-Control headers.
107 + /// `no-cache` tells the browser to store the response but revalidate on each use.
108 + pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
109 + let etag = format!("\"g{}\"", generation);
110 + (
111 + [
112 + (axum::http::header::ETAG, etag),
113 + (axum::http::header::CACHE_CONTROL, "private, no-cache".to_string()),
114 + ],
115 + body,
116 + )
117 + .into_response()
118 + }
119 +
120 + /// Build an HTMX response that shows a toast notification with an empty body.
121 + ///
122 + /// Use for delete/action endpoints that only need to signal success via toast.
123 + pub fn htmx_toast_response(
124 + message: &str,
125 + toast_type: &str,
126 + ) -> ([(&'static str, HeaderValue); 1], axum::response::Html<String>) {
127 + ([("HX-Trigger", hx_toast(message, toast_type))], axum::response::Html(String::new()))
128 + }
129 +
130 + pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
131 + // Strip control characters (C0, DEL/0x7F, C1) before encoding. serde_json
132 + // escapes the < 0x20 controls, but DEL and the C1 range pass through raw and
133 + // make `HeaderValue::from_str` reject the whole header — which silently drops
134 + // the toast. Stripping them keeps the JSON header-safe; the fallback below
135 + // stays as defense-in-depth.
136 + let message: String = message.chars().filter(|c| !c.is_control()).collect();
137 + let toast_type: String = toast_type.chars().filter(|c| !c.is_control()).collect();
138 + let json = serde_json::json!({
139 + "showToast": {
140 + "message": message,
141 + "type": toast_type
142 + }
143 + })
144 + .to_string();
145 + HeaderValue::from_str(&json).unwrap_or_else(|e| {
146 + tracing::warn!(error = %e, "hx_toast produced invalid header value");
147 + HeaderValue::from_static("")
148 + })
149 + }
150 +
151 + #[cfg(test)]
152 + mod tests {
153 + use super::*;
154 +
155 + // ── is_htmx_request ──
156 +
157 + #[test]
158 + fn htmx_request_detected() {
159 + let mut headers = HeaderMap::new();
160 + headers.insert("HX-Request", HeaderValue::from_static("true"));
161 + assert!(is_htmx_request(&headers));
162 + }
163 +
164 + #[test]
165 + fn non_htmx_request() {
166 + let headers = HeaderMap::new();
167 + assert!(!is_htmx_request(&headers));
168 + }
169 +
170 + // ── hx_toast ──
171 +
172 + #[test]
173 + fn hx_toast_produces_valid_json() {
174 + let val = hx_toast("Item deleted", "success");
175 + let s = val.to_str().unwrap();
176 + assert!(s.contains("showToast"));
177 + assert!(s.contains("Item deleted"));
178 + let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
179 + assert_eq!(parsed["showToast"]["message"], "Item deleted");
180 + assert_eq!(parsed["showToast"]["type"], "success");
181 + }
182 +
183 + #[test]
184 + fn hx_toast_error_type() {
185 + let val = hx_toast("Something failed", "error");
186 + let s = val.to_str().unwrap();
187 + let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
188 + assert_eq!(parsed["showToast"]["type"], "error");
189 + }
190 +
191 + #[test]
192 + fn hx_toast_with_quotes() {
193 + let val = hx_toast("Say \"hello\"", "info");
194 + let s = val.to_str().unwrap();
195 + let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
196 + assert_eq!(parsed["showToast"]["message"], "Say \"hello\"");
197 + }
198 +
199 + #[test]
200 + fn adversarial_hx_toast_json_injection() {
201 + let val = hx_toast("\"},{\"malicious\":\"true", "error");
202 + let s = val.to_str().unwrap();
203 + let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
204 + assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true");
205 + }
206 +
207 + // ── extract_client_ip ──
208 +
209 + #[test]
210 + fn extract_client_ip_cf_preferred() {
211 + let mut headers = HeaderMap::new();
212 + headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
213 + headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
214 + assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
215 + }
216 +
217 + #[test]
218 + fn extract_client_ip_ignores_xff_when_cf_missing() {
219 + // XFF alone must not be trusted — see security note on extract_client_ip.
220 + let mut headers = HeaderMap::new();
221 + headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8, 9.10.11.12"));
222 + assert_eq!(extract_client_ip(&headers), None);
223 + }
224 +
225 + #[test]
226 + fn extract_client_ip_ignores_xff_even_when_cf_present() {
227 + // Defense in depth: presence of XFF must not influence the result.
228 + let mut headers = HeaderMap::new();
229 + headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
230 + headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
231 + assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
232 + }
233 +
234 + #[test]
235 + fn extract_client_ip_missing() {
236 + let headers = HeaderMap::new();
237 + assert_eq!(extract_client_ip(&headers), None);
238 + }
239 +
240 + // ── ip_advisory_lock_key ──
241 +
242 + #[test]
243 + fn ip_advisory_lock_key_deterministic() {
244 + assert_eq!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("1.2.3.4"));
245 + }
246 +
247 + #[test]
248 + fn ip_advisory_lock_key_different_ips() {
249 + assert_ne!(ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("5.6.7.8"));
250 + }
251 + }
@@ -0,0 +1,54 @@
1 + //! Cross-service integration helpers: CSRF-token convenience and Multithreaded
2 + //! (MT) discussion-thread stats.
3 +
4 + use tower_sessions::Session;
5 +
6 + use crate::AppState;
7 +
8 + /// Get or create a CSRF token for the session, returning `None` on failure.
9 + ///
10 + /// Convenience wrapper for templates that need an `Option<String>`.
11 + pub async fn get_csrf_token(session: &Session) -> Option<String> {
12 + crate::csrf::get_or_create_token(session).await.ok()
13 + }
14 +
15 + /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
16 + /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
17 + pub async fn fetch_discussion_info(
18 + state: &AppState,
19 + mt_thread_id: Option<crate::db::MtThreadId>,
20 + project_slug: &str,
21 + category_slug: &str,
22 + ) -> (Option<String>, Option<i64>) {
23 + let Some(thread_id) = mt_thread_id else {
24 + return (None, None);
25 + };
26 + let Some(ref mt) = state.mt_client else {
27 + return (None, None);
28 + };
29 + let Some(ref mt_base_url) = state.config.mt_base_url else {
30 + return (None, None);
31 + };
32 +
33 + let url = format!(
34 + "{}/p/{}/{}/{}",
35 + mt_base_url, project_slug, category_slug, thread_id
36 + );
37 +
38 + match tokio::time::timeout(
39 + std::time::Duration::from_secs(2),
40 + mt.get_thread_stats(thread_id),
41 + )
42 + .await
43 + {
44 + Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
45 + Ok(Err(e)) => {
46 + tracing::debug!(error = ?e, "failed to fetch MT thread stats");
47 + (Some(url), None)
48 + }
49 + Err(_) => {
50 + tracing::debug!("MT thread stats request timed out");
51 + (Some(url), None)
52 + }
53 + }
54 + }
@@ -0,0 +1,68 @@
1 + //! Shared utility functions used across routes and modules.
2 + //!
3 + //! Organized into focused submodules and re-exported flat, so existing
4 + //! `crate::helpers::*` imports keep working unchanged:
5 + //!
6 + //! - [`http`] — shared outbound client, client-IP, HTMX/ETag, toast headers
7 + //! - [`db`] — unique-violation (23505) mapping + unique-slug insert loop
8 + //! - [`render`] — Askama fragment rendering + HTML escaping
9 + //! - [`billing`] — Stripe fee estimation + timestamp conversion
10 + //! - [`datetime`] — schedule-datetime parsing
11 + //! - [`integration`] — CSRF-token convenience + MT discussion stats
12 + //!
13 + //! Formatting, crypto, and rate limiting live in their own top-level modules and
14 + //! are re-exported here too, for the same backward-compatibility reason.
15 +
16 + mod billing;
17 + mod datetime;
18 + mod db;
19 + mod http;
20 + mod integration;
21 + mod render;
22 +
23 + pub use billing::{estimate_stripe_fee, stripe_timestamp};
24 + pub use datetime::parse_schedule_datetime;
25 + pub use db::{insert_with_unique_slug, is_unique_violation, map_unique_violation, SLUG_SUFFIX_CAP};
26 + pub use http::{
27 + check_etag, extract_client_ip, hx_toast, htmx_toast_response, ip_advisory_lock_key,
28 + is_htmx_request, with_etag, HTTP_CLIENT,
29 + };
30 + pub use integration::{fetch_discussion_info, get_csrf_token};
31 + pub use render::{escape_html, render_fragment};
32 +
33 + pub use crate::crypto::{
34 + constant_time_compare, generate_feed_url, generate_key_code, verify_feed_signature,
35 + };
36 + pub use crate::formatting::{
37 + format_bytes, format_file_size, format_price, format_revenue, get_initials, sanitize_csv_cell,
38 + slugify,
39 + };
40 + pub use crate::rate_limit::{
41 + rate_limiter_ms, rate_limiter_per_sec, synckit_app_rate_limiter_ms, CloudflareIpKeyExtractor,
42 + SyncAppKeyExtractor,
43 + };
44 +
45 + /// Fire-and-forget email send via the bounded background-task queue. The
46 + /// caller passes `state` (anything that exposes `.email` and `.bg`) so the
47 + /// task is bound by the global background concurrency cap rather than
48 + /// `tokio::spawn`'d unbounded — Run #8 surfaced webhook bursts that could
49 + /// otherwise spawn hundreds of detached email tasks competing with request
50 + /// handlers for the DB pool.
51 + ///
52 + /// Usage:
53 + /// ```ignore
54 + /// spawn_email!(state, "lockout notification", |email| {
55 + /// email.send_lockout_notification(&to, name.as_deref(), Some(&url))
56 + /// });
57 + /// ```
58 + macro_rules! spawn_email {
59 + ($state:expr, $context:literal, |$e:ident| $body:expr) => {{
60 + let $e = $state.email.clone();
61 + $state.bg.spawn($context, async move {
62 + if let Err(e) = $body.await {
63 + tracing::error!(error = ?e, concat!("failed to send ", $context));
64 + }
65 + });
66 + }};
67 + }
68 + pub(crate) use spawn_email;
@@ -0,0 +1,26 @@
1 + //! Rendering helpers: Askama fragment rendering and HTML escaping for raw
2 + //! `format!`-built markup.
3 +
4 + /// Render an Askama fragment to an HTML string, surfacing a render failure as an
5 + /// `AppError` (mapped to 500 + an `HX-Error` header by the error responder) instead
6 + /// of the silent `.render().unwrap_or_default()` blank swap — which installs an
7 + /// empty HTMX fragment with no error signal (ultra-fuzz Run 12 doubledown S-A). Use
8 + /// this for HTMX fragment handlers that return `Result`.
9 + pub fn render_fragment(template: &impl askama::Template) -> crate::error::Result<String> {
10 + template.render().map_err(|e| {
11 + tracing::error!(error = ?e, "fragment render failed");
12 + crate::error::AppError::Internal(anyhow::anyhow!("template render failed"))
13 + })
14 + }
15 +
16 + /// Escape HTML special characters, including `'` so the output is safe in
17 + /// single-quoted attribute contexts as well as element text. Use whenever a
18 + /// value is interpolated into a raw `format!`-built HTML string instead of an
19 + /// auto-escaped template.
20 + pub fn escape_html(s: &str) -> String {
21 + s.replace('&', "&amp;")
22 + .replace('<', "&lt;")
23 + .replace('>', "&gt;")
24 + .replace('"', "&quot;")
25 + .replace('\'', "&#39;")
26 + }