Skip to main content

max / makenotwork

Split helpers.rs into formatting, crypto, rate_limit modules Extract formatting utilities (format_price, format_bytes, slugify, sanitize_csv_cell, get_initials), crypto functions (constant_time_compare, generate_key_code, feed URL signing), and rate limiting (CloudflareIpKeyExtractor, rate_limiter_ms, rate_limiter_per_sec) into focused modules. Re-export from helpers.rs for backward compatibility — no import changes needed. helpers.rs: 1,268 → 290 lines. Tests moved with their functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-02 19:24 UTC
Commit: 285d315067b1639b5cdf33ab5b2541f3a206afb1
Parent: 01c38fb
5 files changed, +743 insertions, -425 deletions
@@ -0,0 +1,166 @@
1 + //! Cryptographic utilities: constant-time comparison, key generation, feed signing.
2 +
3 + /// Constant-time string comparison to prevent timing attacks.
4 + ///
5 + /// Hashes both inputs with SHA-256 before comparing to avoid leaking
6 + /// the length of the expected value via early return.
7 + pub fn constant_time_compare(a: &str, b: &str) -> bool {
8 + use sha2::{Sha256, Digest};
9 +
10 + let hash_a = Sha256::digest(a.as_bytes());
11 + let hash_b = Sha256::digest(b.as_bytes());
12 +
13 + let mut result = 0u8;
14 + for (x, y) in hash_a.iter().zip(hash_b.iter()) {
15 + result |= x ^ y;
16 + }
17 + result == 0
18 + }
19 +
20 + /// Generate a license key code in word-word-word-word-word format.
21 + ///
22 + /// Uses 5 random words from the 2048-word list (~55 bits of entropy).
23 + /// Returns a `KeyCode` via `from_trusted` — the wordlist guarantees validity.
24 + pub fn generate_key_code() -> crate::db::KeyCode {
25 + use rand::Rng;
26 + let mut rng = rand::rng();
27 + let words: Vec<&str> = (0..5)
28 + .map(|_| {
29 + let idx = rng.random_range(0..crate::wordlist::WORDLIST.len());
30 + crate::wordlist::WORDLIST[idx]
31 + })
32 + .collect();
33 + crate::db::KeyCode::from_trusted(words.join("-"))
34 + }
35 +
36 + /// Generate an HMAC-signed personal RSS feed URL for a user.
37 + ///
38 + /// The URL is permanent (no expiry) and tied to the signing secret.
39 + /// If the secret rotates, old URLs become invalid.
40 + pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String {
41 + use hmac::{Hmac, Mac};
42 + use sha2::Sha256;
43 +
44 + let message = format!("feed:{}", user_id);
45 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
46 + .expect("HMAC-SHA256 accepts any key length");
47 + mac.update(message.as_bytes());
48 + let sig = hex::encode(mac.finalize().into_bytes());
49 +
50 + format!("{}/feed/{}?sig={}", host_url, user_id, sig)
51 + }
52 +
53 + /// Verify a personal feed URL signature.
54 + pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool {
55 + use hmac::{Hmac, Mac};
56 + use sha2::Sha256;
57 +
58 + let message = format!("feed:{}", user_id);
59 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
60 + .expect("HMAC-SHA256 accepts any key length");
61 + mac.update(message.as_bytes());
62 + let expected = hex::encode(mac.finalize().into_bytes());
63 +
64 + constant_time_compare(&expected, signature)
65 + }
66 +
67 + #[cfg(test)]
68 + mod tests {
69 + use super::*;
70 +
71 + // ── constant_time_compare ──
72 +
73 + #[test]
74 + fn compare_equal_strings() {
75 + assert!(constant_time_compare("abc123", "abc123"));
76 + }
77 +
78 + #[test]
79 + fn compare_different_strings() {
80 + assert!(!constant_time_compare("abc123", "abc124"));
81 + }
82 +
83 + #[test]
84 + fn compare_different_lengths() {
85 + assert!(!constant_time_compare("short", "longer"));
86 + }
87 +
88 + #[test]
89 + fn compare_empty_strings() {
90 + assert!(constant_time_compare("", ""));
91 + }
92 +
93 + #[test]
94 + fn adversarial_timing_safety() {
95 + assert!(!constant_time_compare("a", "b"));
96 + assert!(!constant_time_compare("a", "aa"));
97 + assert!(!constant_time_compare("", "x"));
98 + assert!(constant_time_compare("same", "same"));
99 + }
100 +
101 + // ── generate_key_code ──
102 +
103 + #[test]
104 + fn key_code_format() {
105 + let code = generate_key_code();
106 + let parts: Vec<&str> = code.split('-').collect();
107 + assert_eq!(parts.len(), 5, "Key code should have 5 words");
108 + for word in &parts {
109 + assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word);
110 + assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word);
111 + assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word);
112 + }
113 + }
114 +
115 + #[test]
116 + fn key_code_uniqueness() {
117 + let codes: std::collections::HashSet<crate::db::KeyCode> = (0..100).map(|_| generate_key_code()).collect();
118 + assert_eq!(codes.len(), 100, "100 generated key codes should all be unique");
119 + }
120 +
121 + // ── feed URL signing ──
122 +
123 + #[test]
124 + fn feed_url_round_trip() {
125 + let user_id = crate::db::UserId::new();
126 + let url = generate_feed_url("https://makenot.work", user_id, "secret");
127 + assert!(url.contains(&user_id.to_string()));
128 + assert!(url.contains("sig="));
129 + let sig = url.split("sig=").nth(1).unwrap();
130 + assert!(verify_feed_signature(user_id, sig, "secret"));
131 + }
132 +
133 + #[test]
134 + fn feed_url_wrong_secret_rejected() {
135 + let user_id = crate::db::UserId::new();
136 + let url = generate_feed_url("https://makenot.work", user_id, "secret");
137 + let sig = url.split("sig=").nth(1).unwrap();
138 + assert!(!verify_feed_signature(user_id, sig, "wrong-secret"));
139 + }
140 +
141 + #[test]
142 + fn feed_url_wrong_user_rejected() {
143 + let user_id = crate::db::UserId::new();
144 + let other_id = crate::db::UserId::new();
145 + let url = generate_feed_url("https://makenot.work", user_id, "secret");
146 + let sig = url.split("sig=").nth(1).unwrap();
147 + assert!(!verify_feed_signature(other_id, sig, "secret"));
148 + }
149 +
150 + #[test]
151 + fn feed_signature_empty_string_rejected() {
152 + let user_id = crate::db::UserId::new();
153 + assert!(!verify_feed_signature(user_id, "", "secret"));
154 + }
155 +
156 + #[test]
157 + fn feed_signature_tampered_rejected() {
158 + let user_id = crate::db::UserId::new();
159 + let url = generate_feed_url("https://makenot.work", user_id, "secret");
160 + let sig = url.split("sig=").nth(1).unwrap();
161 + let mut tampered = sig.to_string();
162 + let first = tampered.remove(0);
163 + tampered.insert(0, if first == '0' { '1' } else { '0' });
164 + assert!(!verify_feed_signature(user_id, &tampered, "secret"));
165 + }
166 + }
@@ -0,0 +1,478 @@
1 + //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells.
2 +
3 + /// Format a price in cents as a human-readable dollar string or "Free".
4 + pub fn format_price(cents: impl Into<i64>) -> String {
5 + let cents: i64 = cents.into();
6 + if cents == 0 {
7 + "Free".to_string()
8 + } else if cents % 100 == 0 {
9 + format!("${}", cents / 100)
10 + } else {
11 + format!("${:.2}", cents as f64 / 100.0)
12 + }
13 + }
14 +
15 + /// Format a revenue amount in cents as a dollar string (always shows decimals).
16 + ///
17 + /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00".
18 + pub fn format_revenue(cents: i64) -> String {
19 + format!("${:.2}", cents as f64 / 100.0)
20 + }
21 +
22 + /// Format a byte count as a human-readable file size string.
23 + /// Returns "N/A" for zero bytes (useful for optional file sizes).
24 + pub fn format_file_size(bytes: i64) -> String {
25 + if bytes == 0 {
26 + return "N/A".to_string();
27 + }
28 + format_bytes(bytes)
29 + }
30 +
31 + /// Format a byte count as a compact human-readable string (e.g. "1.5 GB").
32 + /// Returns "0 B" for zero bytes (useful for storage quota display).
33 + pub fn format_bytes(bytes: i64) -> String {
34 + let bytes = bytes.max(0) as u64;
35 + if bytes < 1024 {
36 + format!("{} B", bytes)
37 + } else if bytes < 1024 * 1024 {
38 + format!("{:.1} KB", bytes as f64 / 1024.0)
39 + } else if bytes < 1024 * 1024 * 1024 {
40 + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
41 + } else {
42 + format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
43 + }
44 + }
45 +
46 + /// Extract up to two uppercase initials from a name for avatar display.
47 + pub fn get_initials(name: &str) -> String {
48 + name.split_whitespace()
49 + .filter_map(|word| word.chars().next())
50 + .take(2)
51 + .collect::<String>()
52 + .to_uppercase()
53 + }
54 +
55 + /// Generate a URL-safe slug from a title.
56 + ///
57 + /// Returns a `Slug` via `from_trusted` — the algorithm guarantees a valid slug.
58 + pub fn slugify(title: &str) -> crate::db::Slug {
59 + let slug: String = title
60 + .to_lowercase()
61 + .chars()
62 + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
63 + .collect();
64 + let mut result = String::new();
65 + let mut prev_hyphen = true;
66 + for c in slug.chars() {
67 + if c == '-' {
68 + if !prev_hyphen {
69 + result.push('-');
70 + }
71 + prev_hyphen = true;
72 + } else {
73 + result.push(c);
74 + prev_hyphen = false;
75 + }
76 + }
77 + if result.ends_with('-') {
78 + result.pop();
79 + }
80 + if result.len() < 2 {
81 + result = "post".to_string();
82 + }
83 + crate::db::Slug::from_trusted(result)
84 + }
85 +
86 + /// Sanitize a string for use as a CSV cell value.
87 + ///
88 + /// Prevents CSV injection by quoting cells and escaping values that start
89 + /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`).
90 + /// Also handles embedded commas, quotes, and newlines per RFC 4180.
91 + pub fn sanitize_csv_cell(value: &str) -> String {
92 + let needs_prefix = value
93 + .chars()
94 + .next()
95 + .map(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r'))
96 + .unwrap_or(false);
97 +
98 + let escaped = value.replace('"', "\"\"");
99 +
100 + if needs_prefix {
101 + format!("\"'{}\"", escaped)
102 + } else if value.contains(',') || value.contains('"') || value.contains('\n') {
103 + format!("\"{}\"", escaped)
104 + } else {
105 + escaped
106 + }
107 + }
108 +
109 + #[cfg(test)]
110 + mod tests {
111 + use super::*;
112 +
113 + // ── format_price ──
114 +
115 + #[test]
116 + fn format_price_free() {
117 + assert_eq!(format_price(0), "Free");
118 + }
119 +
120 + #[test]
121 + fn format_price_whole_dollars() {
122 + assert_eq!(format_price(500), "$5");
123 + assert_eq!(format_price(100), "$1");
124 + assert_eq!(format_price(10000), "$100");
125 + }
126 +
127 + #[test]
128 + fn format_price_with_cents() {
129 + assert_eq!(format_price(999), "$9.99");
130 + assert_eq!(format_price(150), "$1.50");
131 + assert_eq!(format_price(1), "$0.01");
132 + }
133 +
134 + #[test]
135 + fn format_price_negative_cents() {
136 + let result = format_price(-500i64);
137 + assert!(result.contains("-"), "Negative price should show negative sign: {}", result);
138 + }
139 +
140 + #[test]
141 + fn format_price_one_cent() {
142 + assert_eq!(format_price(1), "$0.01");
143 + }
144 +
145 + #[test]
146 + fn format_price_99_cents() {
147 + assert_eq!(format_price(99), "$0.99");
148 + }
149 +
150 + // ── format_revenue ──
151 +
152 + #[test]
153 + fn format_revenue_zero() {
154 + assert_eq!(format_revenue(0), "$0.00");
155 + }
156 +
157 + #[test]
158 + fn format_revenue_whole_dollars() {
159 + assert_eq!(format_revenue(500), "$5.00");
160 + assert_eq!(format_revenue(10000), "$100.00");
161 + }
162 +
163 + #[test]
164 + fn format_revenue_with_cents() {
165 + assert_eq!(format_revenue(999), "$9.99");
166 + assert_eq!(format_revenue(150), "$1.50");
167 + assert_eq!(format_revenue(1), "$0.01");
168 + }
169 +
170 + #[test]
171 + fn format_revenue_large_amount() {
172 + assert_eq!(format_revenue(1_000_000), "$10000.00");
173 + }
174 +
175 + #[test]
176 + fn format_revenue_negative() {
177 + assert_eq!(format_revenue(-500), "$-5.00");
178 + }
179 +
180 + // ── format_file_size ──
181 +
182 + #[test]
183 + fn format_file_size_zero() {
184 + assert_eq!(format_file_size(0), "N/A");
185 + }
186 +
187 + #[test]
188 + fn format_file_size_bytes() {
189 + assert_eq!(format_file_size(512), "512 B");
190 + assert_eq!(format_file_size(1), "1 B");
191 + }
192 +
193 + #[test]
194 + fn format_file_size_kilobytes() {
195 + assert_eq!(format_file_size(1024), "1.0 KB");
196 + assert_eq!(format_file_size(1536), "1.5 KB");
197 + }
198 +
199 + #[test]
200 + fn format_file_size_megabytes() {
201 + assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
202 + assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
203 + }
204 +
205 + #[test]
206 + fn format_file_size_gigabytes() {
207 + assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
208 + assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB");
209 + }
210 +
211 + // ── format_bytes ──
212 +
213 + #[test]
214 + fn format_bytes_zero() {
215 + assert_eq!(format_bytes(0), "0 B");
216 + }
217 +
218 + #[test]
219 + fn format_bytes_small() {
220 + assert_eq!(format_bytes(512), "512 B");
221 + }
222 +
223 + #[test]
224 + fn format_bytes_megabytes() {
225 + assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
226 + }
227 +
228 + #[test]
229 + fn format_bytes_gigabytes() {
230 + assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
231 + }
232 +
233 + #[test]
234 + fn format_bytes_negative_clamped() {
235 + assert_eq!(format_bytes(-100), "0 B");
236 + }
237 +
238 + #[test]
239 + fn format_bytes_exact_kb_boundary() {
240 + assert_eq!(format_bytes(1023), "1023 B");
241 + assert_eq!(format_bytes(1024), "1.0 KB");
242 + }
243 +
244 + #[test]
245 + fn format_bytes_exact_mb_boundary() {
246 + assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB");
247 + assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
248 + }
249 +
250 + #[test]
251 + fn format_bytes_exact_gb_boundary() {
252 + assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB");
253 + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
254 + }
255 +
256 + // ── get_initials ──
257 +
258 + #[test]
259 + fn initials_two_words() {
260 + assert_eq!(get_initials("John Doe"), "JD");
261 + }
262 +
263 + #[test]
264 + fn initials_single_word() {
265 + assert_eq!(get_initials("Alice"), "A");
266 + }
267 +
268 + #[test]
269 + fn initials_three_words_takes_two() {
270 + assert_eq!(get_initials("John Michael Doe"), "JM");
271 + }
272 +
273 + #[test]
274 + fn initials_empty() {
275 + assert_eq!(get_initials(""), "");
276 + }
277 +
278 + #[test]
279 + fn initials_lowercase_uppercased() {
280 + assert_eq!(get_initials("bob smith"), "BS");
281 + }
282 +
283 + #[test]
284 + fn initials_extra_whitespace() {
285 + assert_eq!(get_initials(" John Doe "), "JD");
286 + }
287 +
288 + #[test]
289 + fn initials_unicode() {
290 + assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z");
291 + }
292 +
293 + // ── slugify ──
294 +
295 + #[test]
296 + fn slugify_basic() {
297 + assert_eq!(slugify("Hello World").as_str(), "hello-world");
298 + }
299 +
300 + #[test]
301 + fn slugify_special_chars() {
302 + assert_eq!(slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist");
303 + }
304 +
305 + #[test]
306 + fn slugify_multiple_spaces() {
307 + assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces");
308 + }
309 +
310 + #[test]
311 + fn slugify_leading_trailing_special() {
312 + assert_eq!(slugify("---hello---").as_str(), "hello");
313 + }
314 +
315 + #[test]
316 + fn slugify_unicode() {
317 + let slug = slugify("café résumé");
318 + assert!(slug.contains("caf"));
319 + assert!(!slug.contains(' '));
320 + }
321 +
322 + #[test]
323 + fn slugify_too_short_falls_back() {
324 + assert_eq!(slugify("a").as_str(), "post");
325 + assert_eq!(slugify("").as_str(), "post");
326 + assert_eq!(slugify("---").as_str(), "post");
327 + }
328 +
329 + #[test]
330 + fn slugify_numbers() {
331 + assert_eq!(slugify("Version 2.0").as_str(), "version-2-0");
332 + }
333 +
334 + #[test]
335 + fn slugify_all_special_chars() {
336 + assert_eq!(slugify("!@#$%^&*()").as_str(), "post");
337 + }
338 +
339 + #[test]
340 + fn slugify_single_valid_char() {
341 + assert_eq!(slugify("x").as_str(), "post");
342 + }
343 +
344 + #[test]
345 + fn slugify_two_valid_chars() {
346 + assert_eq!(slugify("ab").as_str(), "ab");
347 + }
348 +
349 + #[test]
350 + fn slugify_mixed_unicode_and_ascii() {
351 + let slug = slugify("café");
352 + assert_eq!(slug.as_str(), "caf");
353 + }
354 +
355 + // ── sanitize_csv_cell ──
356 +
357 + #[test]
358 + fn csv_cell_plain_text() {
359 + assert_eq!(sanitize_csv_cell("Hello World"), "Hello World");
360 + }
361 +
362 + #[test]
363 + fn csv_cell_formula_prefix_equals() {
364 + assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\"");
365 + }
366 +
367 + #[test]
368 + fn csv_cell_formula_prefix_plus() {
369 + assert_eq!(sanitize_csv_cell("+cmd|' /C calc'!A0"), "\"'+cmd|' /C calc'!A0\"");
370 + }
371 +
372 + #[test]
373 + fn csv_cell_formula_prefix_minus() {
374 + assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\"");
375 + }
376 +
377 + #[test]
378 + fn csv_cell_formula_prefix_at() {
379 + assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\"");
380 + }
381 +
382 + #[test]
383 + fn csv_cell_with_comma() {
384 + assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\"");
385 + }
386 +
387 + #[test]
388 + fn csv_cell_with_quotes() {
389 + assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\"");
390 + }
391 +
392 + #[test]
393 + fn csv_cell_empty() {
394 + assert_eq!(sanitize_csv_cell(""), "");
395 + }
396 +
397 + #[test]
398 + fn csv_cell_with_newline() {
399 + assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\"");
400 + }
401 +
402 + #[test]
403 + fn csv_cell_tab_prefix() {
404 + let result = sanitize_csv_cell("\tcmd");
405 + assert!(result.starts_with("\"'"), "Tab prefix should be neutralized: {}", result);
406 + }
407 +
408 + #[test]
409 + fn csv_cell_cr_prefix() {
410 + let result = sanitize_csv_cell("\rcmd");
411 + assert!(result.starts_with("\"'"), "CR prefix should be neutralized: {}", result);
412 + }
413 +
414 + // ── Adversarial ──
415 +
416 + #[test]
417 + fn adversarial_csv_injection_dde() {
418 + let result = sanitize_csv_cell("=cmd|'/C calc'!A0");
419 + assert!(result.starts_with("\"'="), "DDE payload not neutralized: {}", result);
420 + }
421 +
422 + #[test]
423 + fn adversarial_csv_cell_null_bytes() {
424 + let result = sanitize_csv_cell("hello\0world");
425 + assert!(!result.is_empty());
426 + }
427 +
428 + #[test]
429 + fn adversarial_slugify_xss_attempt() {
430 + let slug = slugify("<script>alert('xss')</script>");
431 + assert!(!slug.contains('<'));
432 + assert!(!slug.contains('>'));
433 + }
434 +
435 + #[test]
436 + fn adversarial_slugify_very_long_input() {
437 + let long = "a".repeat(10_000);
438 + let slug = slugify(&long);
439 + assert_eq!(slug.len(), 10_000);
440 + }
441 +
442 + // ── Property-based tests ──
443 +
444 + proptest::proptest! {
445 + #[test]
446 + fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) {
447 + let result = format_price(cents);
448 + proptest::prop_assert!(!result.is_empty());
449 + if cents == 0 {
450 + proptest::prop_assert_eq!(result, "Free");
451 + } else {
452 + proptest::prop_assert!(result.starts_with('$') || result.starts_with("$-"),
453 + "Non-zero price should start with $: {}", result);
454 + }
455 + }
456 +
457 + #[test]
458 + fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) {
459 + let result = format_revenue(cents);
460 + proptest::prop_assert!(result.starts_with('$') || result.starts_with("$-"),
461 + "Revenue should start with $: {}", result);
462 + }
463 +
464 + #[test]
465 + fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) {
466 + let result = format_bytes(bytes);
467 + proptest::prop_assert!(!result.is_empty());
468 + }
469 +
470 + #[test]
471 + fn prop_slugify_never_panics(input in ".*") {
472 + let slug = slugify(&input);
473 + proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
474 + "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str());
475 + proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str());
476 + }
477 + }
478 + }
@@ -1,4 +1,7 @@
1 - //! Shared utility functions used across routes and modules
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.
2 5
3 6 use axum::http::header::HeaderMap;
4 7 use axum::http::HeaderValue;
@@ -8,6 +11,18 @@ use tower_sessions::Session;
8 11
9 12 use crate::AppState;
10 13
14 + // Re-export from focused modules so existing `crate::helpers::X` paths still work.
15 + pub use crate::formatting::{
16 + format_bytes, format_file_size, format_price, format_revenue,
17 + get_initials, sanitize_csv_cell, slugify,
18 + };
19 + pub use crate::crypto::{
20 + constant_time_compare, generate_feed_url, generate_key_code, verify_feed_signature,
21 + };
22 + pub use crate::rate_limit::{
23 + rate_limiter_ms, rate_limiter_per_sec, CloudflareIpKeyExtractor,
24 + };
25 +
11 26 /// Extract the client IP from request headers.
12 27 ///
13 28 /// Prefers `CF-Connecting-IP` (set by Cloudflare, trusted) over `X-Forwarded-For`.
@@ -27,7 +42,6 @@ pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
27 42 /// Uses a simple hash to map arbitrary IP strings to the i64 keyspace.
28 43 pub fn ip_advisory_lock_key(ip: &str) -> i64 {
29 44 use std::hash::{Hash, Hasher};
30 - // Namespace prefix to avoid collisions with other advisory lock users.
31 45 let mut hasher = std::collections::hash_map::DefaultHasher::new();
32 46 "sandbox_ip_cap".hash(&mut hasher);
33 47 ip.hash(&mut hasher);
@@ -78,101 +92,6 @@ pub async fn get_csrf_token(session: &Session) -> Option<String> {
78 92 crate::csrf::get_or_create_token(session).await.ok()
79 93 }
80 94
81 - /// Constant-time string comparison to prevent timing attacks.
82 - ///
83 - /// Hashes both inputs with SHA-256 before comparing to avoid leaking
84 - /// the length of the expected value via early return.
85 - pub fn constant_time_compare(a: &str, b: &str) -> bool {
86 - use sha2::{Sha256, Digest};
87 -
88 - let hash_a = Sha256::digest(a.as_bytes());
89 - let hash_b = Sha256::digest(b.as_bytes());
90 -
91 - let mut result = 0u8;
92 - for (x, y) in hash_a.iter().zip(hash_b.iter()) {
93 - result |= x ^ y;
94 - }
95 - result == 0
96 - }
97 -
98 - /// Generate a URL-safe slug from a title.
99 - ///
100 - /// Returns a `Slug` via `from_trusted` — the algorithm guarantees a valid slug.
101 - pub fn slugify(title: &str) -> crate::db::Slug {
102 - let slug: String = title
103 - .to_lowercase()
104 - .chars()
105 - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
106 - .collect();
107 - // Collapse multiple hyphens, trim from ends
108 - let mut result = String::new();
109 - let mut prev_hyphen = true; // start true to trim leading hyphens
110 - for c in slug.chars() {
111 - if c == '-' {
112 - if !prev_hyphen {
113 - result.push('-');
114 - }
115 - prev_hyphen = true;
116 - } else {
117 - result.push(c);
118 - prev_hyphen = false;
119 - }
120 - }
121 - // Trim trailing hyphen
122 - if result.ends_with('-') {
123 - result.pop();
124 - }
125 - if result.len() < 2 {
126 - result = "post".to_string();
127 - }
128 - crate::db::Slug::from_trusted(result)
129 - }
130 -
131 - /// Format a price in cents as a human-readable dollar string or "Free".
132 - pub fn format_price(cents: impl Into<i64>) -> String {
133 - let cents: i64 = cents.into();
134 - if cents == 0 {
135 - "Free".to_string()
136 - } else if cents % 100 == 0 {
137 - // Whole dollar amount - no decimals
138 - format!("${}", cents / 100)
139 - } else {
140 - // Has cents - show decimals
141 - format!("${:.2}", cents as f64 / 100.0)
142 - }
143 - }
144 -
145 - /// Format a revenue amount in cents as a dollar string (always shows decimals).
146 - ///
147 - /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00".
148 - pub fn format_revenue(cents: i64) -> String {
149 - format!("${:.2}", cents as f64 / 100.0)
150 - }
151 -
152 - /// Format a byte count as a human-readable file size string.
153 - /// Returns "N/A" for zero bytes (useful for optional file sizes).
154 - pub fn format_file_size(bytes: i64) -> String {
155 - if bytes == 0 {
156 - return "N/A".to_string();
157 - }
158 - format_bytes(bytes)
159 - }
160 -
161 - /// Format a byte count as a compact human-readable string (e.g. "1.5 GB").
162 - /// Returns "0 B" for zero bytes (useful for storage quota display).
163 - pub fn format_bytes(bytes: i64) -> String {
164 - let bytes = bytes.max(0) as u64;
165 - if bytes < 1024 {
166 - format!("{} B", bytes)
167 - } else if bytes < 1024 * 1024 {
168 - format!("{:.1} KB", bytes as f64 / 1024.0)
169 - } else if bytes < 1024 * 1024 * 1024 {
170 - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
171 - } else {
172 - format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
173 - }
174 - }
175 -
176 95 /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now.
177 96 pub fn stripe_timestamp(ts: i64) -> chrono::DateTime<chrono::Utc> {
178 97 chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now)
@@ -199,74 +118,18 @@ pub fn parse_schedule_datetime(s: Option<&str>) -> Option<Option<chrono::DateTim
199 118 })
200 119 }
201 120
202 - /// IP key extractor that prefers `CF-Connecting-IP` (set by Cloudflare, cannot
203 - /// be spoofed by clients) over `X-Forwarded-For` (which can be spoofed if the
204 - /// proxy chain doesn't strip it). Falls back to `SmartIpKeyExtractor` behavior
205 - /// when `CF-Connecting-IP` is absent (e.g., direct/dev access without Cloudflare).
206 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
207 - pub struct CloudflareIpKeyExtractor;
208 -
209 - impl tower_governor::key_extractor::KeyExtractor for CloudflareIpKeyExtractor {
210 - type Key = std::net::IpAddr;
211 -
212 - fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, tower_governor::errors::GovernorError> {
213 - // Prefer CF-Connecting-IP (trusted, set by Cloudflare edge)
214 - if let Some(ip) = req
215 - .headers()
216 - .get("cf-connecting-ip")
217 - .and_then(|v: &axum::http::HeaderValue| v.to_str().ok())
218 - .and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok())
219 - {
220 - return Ok(ip);
221 - }
222 -
223 - // Fall back to SmartIpKeyExtractor behavior for non-Cloudflare environments
224 - tower_governor::key_extractor::SmartIpKeyExtractor.extract(req)
121 + /// Estimate Stripe's processing fee and the net amount the creator receives.
122 + ///
123 + /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard
124 + /// Stripe rate from [`constants`](crate::constants).
125 + pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) {
126 + if price_cents <= 0 {
127 + return (0, 0);
225 128 }
226 - }
227 -
228 - /// Build a rate limiter config from a per-millisecond interval and burst size.
229 - /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers.
230 - pub fn rate_limiter_ms(
231 - ms: u64,
232 - burst: u32,
233 - ) -> std::sync::Arc<
234 - tower_governor::governor::GovernorConfig<
235 - CloudflareIpKeyExtractor,
236 - ::governor::middleware::StateInformationMiddleware,
237 - >,
238 - > {
239 - std::sync::Arc::new(
240 - tower_governor::governor::GovernorConfigBuilder::default()
241 - .key_extractor(CloudflareIpKeyExtractor)
242 - .per_millisecond(ms)
243 - .burst_size(burst)
244 - .use_headers()
245 - .finish()
246 - .expect("rate limiter config"),
247 - )
248 - }
249 -
250 - /// Build a rate limiter config from a per-second rate and burst size.
251 - /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers.
252 - pub fn rate_limiter_per_sec(
253 - per_sec: u64,
254 - burst: u32,
255 - ) -> std::sync::Arc<
256 - tower_governor::governor::GovernorConfig<
257 - CloudflareIpKeyExtractor,
258 - ::governor::middleware::StateInformationMiddleware,
259 - >,
260 - > {
261 - std::sync::Arc::new(
262 - tower_governor::governor::GovernorConfigBuilder::default()
263 - .key_extractor(CloudflareIpKeyExtractor)
264 - .per_second(per_sec)
265 - .burst_size(burst)
266 - .use_headers()
267 - .finish()
268 - .expect("rate limiter config"),
269 - )
129 + let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE
130 + + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32;
131 + let creator_receives = (price_cents - fee).max(0);
132 + (fee.min(price_cents), creator_receives)
270 133 }
271 134
272 135 /// Build an HTMX response that shows a toast notification with an empty body.
@@ -293,100 +156,6 @@ pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
293 156 })
294 157 }
295 158
296 - /// Extract up to two uppercase initials from a name for avatar display.
297 - pub fn get_initials(name: &str) -> String {
298 - name.split_whitespace()
299 - .filter_map(|word| word.chars().next())
300 - .take(2)
301 - .collect::<String>()
302 - .to_uppercase()
303 - }
304 -
305 - /// Generate a license key code in word-word-word-word-word format.
306 - ///
307 - /// Uses 5 random words from the 2048-word list (~55 bits of entropy).
308 - /// Returns a `KeyCode` via `from_trusted` — the wordlist guarantees validity.
309 - pub fn generate_key_code() -> crate::db::KeyCode {
310 - use rand::Rng;
311 - let mut rng = rand::rng();
312 - let words: Vec<&str> = (0..5)
313 - .map(|_| {
314 - let idx = rng.random_range(0..crate::wordlist::WORDLIST.len());
315 - crate::wordlist::WORDLIST[idx]
316 - })
317 - .collect();
318 - crate::db::KeyCode::from_trusted(words.join("-"))
319 - }
320 -
321 - /// Estimate Stripe's processing fee and the net amount the creator receives.
322 - ///
323 - /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard
324 - /// Stripe rate from [`constants`](crate::constants).
325 - pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) {
326 - if price_cents <= 0 {
327 - return (0, 0);
328 - }
329 - let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE
330 - + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32;
331 - let creator_receives = (price_cents - fee).max(0);
332 - (fee.min(price_cents), creator_receives)
333 - }
334 -
335 - /// Sanitize a string for use as a CSV cell value.
336 - ///
337 - /// Prevents CSV injection by quoting cells and escaping values that start
338 - /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`).
339 - /// Also handles embedded commas, quotes, and newlines per RFC 4180.
340 - pub fn sanitize_csv_cell(value: &str) -> String {
341 - let needs_prefix = value
342 - .chars()
343 - .next()
344 - .map(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r'))
345 - .unwrap_or(false);
346 -
347 - let escaped = value.replace('"', "\"\"");
348 -
349 - if needs_prefix {
350 - // Prefix with a single quote inside the quoted field to neutralize formulas
351 - format!("\"'{}\"", escaped)
352 - } else if value.contains(',') || value.contains('"') || value.contains('\n') {
353 - format!("\"{}\"", escaped)
354 - } else {
355 - escaped
356 - }
357 - }
358 -
359 - /// Generate an HMAC-signed personal RSS feed URL for a user.
360 - ///
361 - /// The URL is permanent (no expiry) and tied to the signing secret.
362 - /// If the secret rotates, old URLs become invalid.
363 - pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String {
364 - use hmac::{Hmac, Mac};
365 - use sha2::Sha256;
366 -
367 - let message = format!("feed:{}", user_id);
368 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
369 - .expect("HMAC-SHA256 accepts any key length");
370 - mac.update(message.as_bytes());
371 - let sig = hex::encode(mac.finalize().into_bytes());
372 -
373 - format!("{}/feed/{}?sig={}", host_url, user_id, sig)
374 - }
375 -
376 - /// Verify a personal feed URL signature.
377 - pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool {
378 - use hmac::{Hmac, Mac};
379 - use sha2::Sha256;
380 -
381 - let message = format!("feed:{}", user_id);
382 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
383 - .expect("HMAC-SHA256 accepts any key length");
384 - mac.update(message.as_bytes());
385 - let expected = hex::encode(mac.finalize().into_bytes());
386 -
387 - constant_time_compare(&expected, signature)
388 - }
389 -
390 159 /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
391 160 /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
392 161 pub async fn fetch_discussion_info(
@@ -419,7 +188,6 @@ pub async fn fetch_discussion_info(
419 188 Ok(Ok(stats)) => (Some(url), Some(stats.post_count)),
420 189 Ok(Err(e)) => {
421 190 tracing::debug!(error = ?e, "failed to fetch MT thread stats");
422 - // Still return the URL even if stats failed
423 191 (Some(url), None)
424 192 }
425 193 Err(_) => {
@@ -468,172 +236,6 @@ mod tests {
468 236 assert!(!is_htmx_request(&headers));
469 237 }
470 238
471 - // ── constant_time_compare ──
472 -
473 - #[test]
474 - fn compare_equal_strings() {
475 - assert!(constant_time_compare("abc123", "abc123"));
476 - }
477 -
478 - #[test]
479 - fn compare_different_strings() {
480 - assert!(!constant_time_compare("abc123", "abc124"));
481 - }
482 -
483 - #[test]
484 - fn compare_different_lengths() {
485 - assert!(!constant_time_compare("short", "longer"));
486 - }
487 -
488 - #[test]
489 - fn compare_empty_strings() {
490 - assert!(constant_time_compare("", ""));
491 - }
492 -
493 - // ── slugify ──
494 -
495 - #[test]
496 - fn slugify_basic() {
497 - assert_eq!(slugify("Hello World").as_str(), "hello-world");
498 - }
499 -
500 - #[test]
501 - fn slugify_special_chars() {
502 - assert_eq!(slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist");
503 - }
504 -
505 - #[test]
506 - fn slugify_multiple_spaces() {
507 - assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces");
508 - }
509 -
510 - #[test]
511 - fn slugify_leading_trailing_special() {
512 - assert_eq!(slugify("---hello---").as_str(), "hello");
513 - }
514 -
515 - #[test]
516 - fn slugify_unicode() {
517 - // Non-ASCII alphanumeric chars are kept by is_alphanumeric
518 - let slug = slugify("café résumé");
519 - assert!(slug.contains("caf"));
520 - assert!(!slug.contains(' '));
521 - }
522 -
523 - #[test]
524 - fn slugify_too_short_falls_back() {
525 - assert_eq!(slugify("a").as_str(), "post");
526 - assert_eq!(slugify("").as_str(), "post");
527 - assert_eq!(slugify("---").as_str(), "post");
528 - }
529 -
530 - #[test]
531 - fn slugify_numbers() {
532 - assert_eq!(slugify("Version 2.0").as_str(), "version-2-0");
533 - }
534 -
535 - // ── format_price ──
536 -
537 - #[test]
538 - fn format_price_free() {
539 - assert_eq!(format_price(0), "Free");
540 - }
541 -
542 - #[test]
543 - fn format_price_whole_dollars() {
544 - assert_eq!(format_price(500), "$5");
545 - assert_eq!(format_price(100), "$1");
546 - assert_eq!(format_price(10000), "$100");
547 - }
548 -
549 - #[test]
550 - fn format_price_with_cents() {
551 - assert_eq!(format_price(999), "$9.99");
552 - assert_eq!(format_price(150), "$1.50");
553 - assert_eq!(format_price(1), "$0.01");
554 - }
555 -
556 - // ── format_revenue ──
557 -
558 - #[test]
559 - fn format_revenue_zero() {
560 - assert_eq!(format_revenue(0), "$0.00");
561 - }
562 -
563 - #[test]
564 - fn format_revenue_whole_dollars() {
565 - assert_eq!(format_revenue(500), "$5.00");
566 - assert_eq!(format_revenue(10000), "$100.00");
567 - }
568 -
569 - #[test]
570 - fn format_revenue_with_cents() {
571 - assert_eq!(format_revenue(999), "$9.99");
572 - assert_eq!(format_revenue(150), "$1.50");
573 - assert_eq!(format_revenue(1), "$0.01");
574 - }
575 -
576 - #[test]
577 - fn format_revenue_large_amount() {
578 - assert_eq!(format_revenue(1_000_000), "$10000.00");
579 - }
580 -
581 - // ── format_file_size ──
582 -
583 - #[test]
584 - fn format_file_size_zero() {
585 - assert_eq!(format_file_size(0), "N/A");
586 - }
587 -
588 - #[test]
589 - fn format_file_size_bytes() {
590 - assert_eq!(format_file_size(512), "512 B");
591 - assert_eq!(format_file_size(1), "1 B");
592 - }
593 -
594 - #[test]
595 - fn format_file_size_kilobytes() {
596 - assert_eq!(format_file_size(1024), "1.0 KB");
597 - assert_eq!(format_file_size(1536), "1.5 KB");
598 - }
599 -
600 - #[test]
601 - fn format_file_size_megabytes() {
602 - assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
603 - assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
604 - }
605 -
606 - #[test]
607 - fn format_bytes_zero() {
608 - assert_eq!(format_bytes(0), "0 B");
609 - }
610 -
611 - #[test]
612 - fn format_bytes_small() {
613 - assert_eq!(format_bytes(512), "512 B");
614 - }
615 -
616 - #[test]
617 - fn format_bytes_megabytes() {
618 - assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
619 - }
620 -
621 - #[test]
622 - fn format_bytes_gigabytes() {
623 - assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
624 - }
625 -
626 - #[test]
627 - fn format_bytes_negative_clamped() {
628 - assert_eq!(format_bytes(-100), "0 B");
629 - }
630 -
631 - #[test]
632 - fn format_file_size_gigabytes() {
633 - assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
634 - assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB");
635 - }
636 -
637 239 // ── hx_toast ──
638 240
639 241 #[test]
@@ -642,8 +244,6 @@ mod tests {
642 244 let s = val.to_str().unwrap();
643 245 assert!(s.contains("showToast"));
Lines truncated
@@ -11,7 +11,10 @@ pub mod error;
11 11 pub mod git;
12 12 pub mod git_ssh;
13 13 pub mod license_templates;
14 + pub mod crypto;
15 + pub mod formatting;
14 16 pub mod helpers;
17 + pub mod rate_limit;
15 18 pub mod import;
16 19 pub mod markdown;
17 20 pub mod metrics;
@@ -0,0 +1,69 @@
1 + //! Rate limiting: Cloudflare-aware IP extraction and governor config builders.
2 +
3 + /// IP key extractor that prefers `CF-Connecting-IP` (set by Cloudflare, cannot
4 + /// be spoofed by clients) over `X-Forwarded-For` (which can be spoofed if the
5 + /// proxy chain doesn't strip it). Falls back to `SmartIpKeyExtractor` behavior
6 + /// when `CF-Connecting-IP` is absent (e.g., direct/dev access without Cloudflare).
7 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
8 + pub struct CloudflareIpKeyExtractor;
9 +
10 + impl tower_governor::key_extractor::KeyExtractor for CloudflareIpKeyExtractor {
11 + type Key = std::net::IpAddr;
12 +
13 + fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, tower_governor::errors::GovernorError> {
14 + if let Some(ip) = req
15 + .headers()
16 + .get("cf-connecting-ip")
17 + .and_then(|v: &axum::http::HeaderValue| v.to_str().ok())
18 + .and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok())
19 + {
20 + return Ok(ip);
21 + }
22 +
23 + tower_governor::key_extractor::SmartIpKeyExtractor.extract(req)
24 + }
25 + }
26 +
27 + /// Build a rate limiter config from a per-millisecond interval and burst size.
28 + /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers.
29 + pub fn rate_limiter_ms(
30 + ms: u64,
31 + burst: u32,
32 + ) -> std::sync::Arc<
33 + tower_governor::governor::GovernorConfig<
34 + CloudflareIpKeyExtractor,
35 + ::governor::middleware::StateInformationMiddleware,
36 + >,
37 + > {
38 + std::sync::Arc::new(
39 + tower_governor::governor::GovernorConfigBuilder::default()
40 + .key_extractor(CloudflareIpKeyExtractor)
41 + .per_millisecond(ms)
42 + .burst_size(burst)
43 + .use_headers()
44 + .finish()
45 + .expect("rate limiter config"),
46 + )
47 + }
48 +
49 + /// Build a rate limiter config from a per-second rate and burst size.
50 + /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers.
51 + pub fn rate_limiter_per_sec(
52 + per_sec: u64,
53 + burst: u32,
54 + ) -> std::sync::Arc<
55 + tower_governor::governor::GovernorConfig<
56 + CloudflareIpKeyExtractor,
57 + ::governor::middleware::StateInformationMiddleware,
58 + >,
59 + > {
60 + std::sync::Arc::new(
61 + tower_governor::governor::GovernorConfigBuilder::default()
62 + .key_extractor(CloudflareIpKeyExtractor)
63 + .per_second(per_sec)
64 + .burst_size(burst)
65 + .use_headers()
66 + .finish()
67 + .expect("rate limiter config"),
68 + )
69 + }