Skip to main content

karbon_framework/util/
http.rs

1use axum::http::HeaderMap;
2use std::net::{IpAddr, SocketAddr};
3
4/// Parsed User-Agent info
5#[derive(Debug, Clone, Default)]
6pub struct UserAgentInfo {
7    pub browser: Option<String>,
8    pub browser_version: Option<String>,
9    pub os: Option<String>,
10    pub is_mobile: bool,
11    pub is_bot: bool,
12    pub raw: String,
13}
14
15/// HTTP request helpers for extracting client info
16pub struct HttpHelper;
17
18impl HttpHelper {
19    // ─── User-Agent ───
20
21    /// Parse a User-Agent string into structured info
22    pub fn parse_user_agent(ua: &str) -> UserAgentInfo {
23        if ua.is_empty() {
24            return UserAgentInfo::default();
25        }
26
27        let is_bot = Self::detect_bot(ua);
28        let is_mobile = ua.contains("Mobile")
29            || ua.contains("Android")
30            || ua.contains("iPhone")
31            || ua.contains("iPad");
32
33        let (browser, browser_version) = Self::detect_browser(ua);
34        let os = Self::detect_os(ua);
35
36        UserAgentInfo {
37            browser,
38            browser_version,
39            os,
40            is_mobile,
41            is_bot,
42            raw: ua.to_string(),
43        }
44    }
45
46    /// Parse User-Agent directly from headers
47    pub fn parse_user_agent_from_headers(headers: &HeaderMap) -> UserAgentInfo {
48        let ua = Self::get_header(headers, "user-agent").unwrap_or_default();
49        Self::parse_user_agent(&ua)
50    }
51
52    fn detect_browser(ua: &str) -> (Option<String>, Option<String>) {
53        // Order matters: check more specific patterns first
54        let patterns: &[(&str, &str, &str)] = &[
55            ("OPR/", "Opera", "OPR/"),
56            ("Edg/", "Edge", "Edg/"),
57            ("Firefox/", "Firefox", "Firefox/"),
58            ("Vivaldi/", "Vivaldi", "Vivaldi/"),
59            ("Brave", "Brave", "Chrome/"),
60            // Chrome must come after Edge, Opera, Vivaldi, Brave
61            ("Chrome/", "Chrome", "Chrome/"),
62            // Safari must come last (many browsers include Safari in UA)
63            ("Safari/", "Safari", "Version/"),
64        ];
65
66        for (marker, name, version_prefix) in patterns {
67            if ua.contains(marker) {
68                let version = Self::extract_version(ua, version_prefix);
69                return (Some(name.to_string()), version);
70            }
71        }
72
73        if !ua.is_empty() {
74            (Some("Other".to_string()), None)
75        } else {
76            (None, None)
77        }
78    }
79
80    fn detect_os(ua: &str) -> Option<String> {
81        // Order matters: Android contains "Linux", iOS check before Mac
82        if ua.contains("Android") {
83            Some("Android".to_string())
84        } else if ua.contains("iPhone") || ua.contains("iPad") || ua.contains("iPod") {
85            Some("iOS".to_string())
86        } else if ua.contains("Windows NT 10") {
87            Some("Windows 10/11".to_string())
88        } else if ua.contains("Windows") {
89            Some("Windows".to_string())
90        } else if ua.contains("Mac OS X") || ua.contains("macOS") {
91            Some("macOS".to_string())
92        } else if ua.contains("CrOS") {
93            Some("Chrome OS".to_string())
94        } else if ua.contains("Linux") {
95            Some("Linux".to_string())
96        } else {
97            None
98        }
99    }
100
101    fn detect_bot(ua: &str) -> bool {
102        let bot_markers = [
103            "bot", "Bot", "crawler", "Crawler", "spider", "Spider",
104            "Googlebot", "Bingbot", "Slurp", "DuckDuckBot", "Baiduspider",
105            "YandexBot", "facebookexternalhit", "Twitterbot", "LinkedInBot",
106            "WhatsApp", "Discordbot", "Slackbot", "TelegramBot",
107            "curl/", "wget/", "python-requests", "Go-http-client",
108            "HeadlessChrome", "PhantomJS", "Lighthouse",
109        ];
110        bot_markers.iter().any(|m| ua.contains(m))
111    }
112
113    /// Extract version number from a UA pattern like "Chrome/120.0.6099.130"
114    fn extract_version(ua: &str, prefix: &str) -> Option<String> {
115        ua.find(prefix).map(|pos| {
116            let start = pos + prefix.len();
117            let version: String = ua[start..]
118                .chars()
119                .take_while(|c| c.is_ascii_digit() || *c == '.')
120                .collect();
121            version
122        }).filter(|v| !v.is_empty())
123    }
124
125    // ─── IP Address ───
126
127    /// Extract client IP from headers (X-Forwarded-For, X-Real-Ip) with fallback
128    pub fn client_ip(headers: &HeaderMap, fallback: IpAddr) -> IpAddr {
129        // X-Forwarded-For: client, proxy1, proxy2 → take first
130        if let Some(forwarded) = Self::get_header(headers, "x-forwarded-for") {
131            if let Some(first) = forwarded.split(',').next() {
132                if let Ok(ip) = first.trim().parse::<IpAddr>() {
133                    return ip;
134                }
135            }
136        }
137
138        // X-Real-Ip: single IP set by reverse proxy
139        if let Some(real_ip) = Self::get_header(headers, "x-real-ip") {
140            if let Ok(ip) = real_ip.trim().parse::<IpAddr>() {
141                return ip;
142            }
143        }
144
145        fallback
146    }
147
148    /// Extract client IP as string, with SocketAddr fallback (common Axum pattern)
149    pub fn client_ip_from_socket(headers: &HeaderMap, socket: &SocketAddr) -> String {
150        Self::client_ip(headers, socket.ip()).to_string()
151    }
152
153    // ─── Header Helpers ───
154
155    /// Get a header value as string
156    pub fn get_header(headers: &HeaderMap, name: &str) -> Option<String> {
157        headers
158            .get(name)
159            .and_then(|v| v.to_str().ok())
160            .map(|s| s.to_string())
161    }
162
163    /// Get the Referer header
164    pub fn referer(headers: &HeaderMap) -> Option<String> {
165        Self::get_header(headers, "referer")
166    }
167
168    /// Get the Accept-Language header and extract the primary language
169    /// e.g., "fr-FR,fr;q=0.9,en-US;q=0.8" → "fr-FR"
170    pub fn accept_language(headers: &HeaderMap) -> Option<String> {
171        Self::get_header(headers, "accept-language")
172            .and_then(|val| val.split(',').next().map(|s| s.trim().to_string()))
173    }
174
175    /// Get the Content-Type header
176    pub fn content_type(headers: &HeaderMap) -> Option<String> {
177        Self::get_header(headers, "content-type")
178    }
179
180    /// Check if the request accepts JSON
181    pub fn accepts_json(headers: &HeaderMap) -> bool {
182        Self::get_header(headers, "accept")
183            .map(|v| v.contains("application/json"))
184            .unwrap_or(false)
185    }
186
187    /// Get the Authorization Bearer token
188    pub fn bearer_token(headers: &HeaderMap) -> Option<String> {
189        Self::get_header(headers, "authorization")
190            .filter(|v| v.starts_with("Bearer "))
191            .map(|v| v[7..].to_string())
192    }
193
194    /// Check if the request is an AJAX/XHR request
195    pub fn is_xhr(headers: &HeaderMap) -> bool {
196        Self::get_header(headers, "x-requested-with")
197            .map(|v| v.eq_ignore_ascii_case("XMLHttpRequest"))
198            .unwrap_or(false)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    // ─── User-Agent parsing ───
207
208    #[test]
209    fn test_chrome_windows() {
210        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.130 Safari/537.36";
211        let info = HttpHelper::parse_user_agent(ua);
212        assert_eq!(info.browser.as_deref(), Some("Chrome"));
213        assert_eq!(info.browser_version.as_deref(), Some("120.0.6099.130"));
214        assert_eq!(info.os.as_deref(), Some("Windows 10/11"));
215        assert!(!info.is_mobile);
216        assert!(!info.is_bot);
217    }
218
219    #[test]
220    fn test_firefox_linux() {
221        let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0";
222        let info = HttpHelper::parse_user_agent(ua);
223        assert_eq!(info.browser.as_deref(), Some("Firefox"));
224        assert_eq!(info.browser_version.as_deref(), Some("121.0"));
225        assert_eq!(info.os.as_deref(), Some("Linux"));
226    }
227
228    #[test]
229    fn test_safari_macos() {
230        let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15";
231        let info = HttpHelper::parse_user_agent(ua);
232        assert_eq!(info.browser.as_deref(), Some("Safari"));
233        assert_eq!(info.browser_version.as_deref(), Some("17.2"));
234        assert_eq!(info.os.as_deref(), Some("macOS"));
235    }
236
237    #[test]
238    fn test_edge() {
239        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91";
240        let info = HttpHelper::parse_user_agent(ua);
241        assert_eq!(info.browser.as_deref(), Some("Edge"));
242        assert_eq!(info.browser_version.as_deref(), Some("120.0.2210.91"));
243    }
244
245    #[test]
246    fn test_mobile_android() {
247        let ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36";
248        let info = HttpHelper::parse_user_agent(ua);
249        assert_eq!(info.browser.as_deref(), Some("Chrome"));
250        assert_eq!(info.os.as_deref(), Some("Android"));
251        assert!(info.is_mobile);
252    }
253
254    #[test]
255    fn test_iphone_safari() {
256        let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1";
257        let info = HttpHelper::parse_user_agent(ua);
258        assert_eq!(info.browser.as_deref(), Some("Safari"));
259        assert_eq!(info.os.as_deref(), Some("iOS"));
260        assert!(info.is_mobile);
261    }
262
263    #[test]
264    fn test_googlebot() {
265        let ua = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
266        let info = HttpHelper::parse_user_agent(ua);
267        assert!(info.is_bot);
268    }
269
270    #[test]
271    fn test_curl() {
272        let ua = "curl/8.4.0";
273        let info = HttpHelper::parse_user_agent(ua);
274        assert!(info.is_bot);
275    }
276
277    #[test]
278    fn test_empty_ua() {
279        let info = HttpHelper::parse_user_agent("");
280        assert!(info.browser.is_none());
281        assert!(info.os.is_none());
282        assert!(!info.is_mobile);
283        assert!(!info.is_bot);
284    }
285
286    #[test]
287    fn test_opera() {
288        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0";
289        let info = HttpHelper::parse_user_agent(ua);
290        assert_eq!(info.browser.as_deref(), Some("Opera"));
291    }
292
293    // ─── IP extraction ───
294
295    #[test]
296    fn test_ip_from_x_forwarded_for() {
297        let mut headers = HeaderMap::new();
298        headers.insert("x-forwarded-for", "203.0.113.50, 70.41.3.18".parse().unwrap());
299        let fallback: IpAddr = "127.0.0.1".parse().unwrap();
300        let ip = HttpHelper::client_ip(&headers, fallback);
301        assert_eq!(ip.to_string(), "203.0.113.50");
302    }
303
304    #[test]
305    fn test_ip_from_x_real_ip() {
306        let mut headers = HeaderMap::new();
307        headers.insert("x-real-ip", "203.0.113.50".parse().unwrap());
308        let fallback: IpAddr = "127.0.0.1".parse().unwrap();
309        let ip = HttpHelper::client_ip(&headers, fallback);
310        assert_eq!(ip.to_string(), "203.0.113.50");
311    }
312
313    #[test]
314    fn test_ip_forwarded_takes_priority() {
315        let mut headers = HeaderMap::new();
316        headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
317        headers.insert("x-real-ip", "5.6.7.8".parse().unwrap());
318        let fallback: IpAddr = "127.0.0.1".parse().unwrap();
319        let ip = HttpHelper::client_ip(&headers, fallback);
320        assert_eq!(ip.to_string(), "1.2.3.4");
321    }
322
323    #[test]
324    fn test_ip_fallback() {
325        let headers = HeaderMap::new();
326        let fallback: IpAddr = "127.0.0.1".parse().unwrap();
327        let ip = HttpHelper::client_ip(&headers, fallback);
328        assert_eq!(ip.to_string(), "127.0.0.1");
329    }
330
331    #[test]
332    fn test_ip_from_socket() {
333        let headers = HeaderMap::new();
334        let socket: SocketAddr = "192.168.1.1:8080".parse().unwrap();
335        let ip = HttpHelper::client_ip_from_socket(&headers, &socket);
336        assert_eq!(ip, "192.168.1.1");
337    }
338
339    // ─── Header helpers ───
340
341    #[test]
342    fn test_get_header() {
343        let mut headers = HeaderMap::new();
344        headers.insert("x-custom", "value123".parse().unwrap());
345        assert_eq!(HttpHelper::get_header(&headers, "x-custom").as_deref(), Some("value123"));
346        assert!(HttpHelper::get_header(&headers, "x-missing").is_none());
347    }
348
349    #[test]
350    fn test_bearer_token() {
351        let mut headers = HeaderMap::new();
352        headers.insert("authorization", "Bearer abc123xyz".parse().unwrap());
353        assert_eq!(HttpHelper::bearer_token(&headers).as_deref(), Some("abc123xyz"));
354    }
355
356    #[test]
357    fn test_bearer_token_missing() {
358        let headers = HeaderMap::new();
359        assert!(HttpHelper::bearer_token(&headers).is_none());
360    }
361
362    #[test]
363    fn test_bearer_token_wrong_scheme() {
364        let mut headers = HeaderMap::new();
365        headers.insert("authorization", "Basic abc123".parse().unwrap());
366        assert!(HttpHelper::bearer_token(&headers).is_none());
367    }
368
369    #[test]
370    fn test_accept_language() {
371        let mut headers = HeaderMap::new();
372        headers.insert("accept-language", "fr-FR,fr;q=0.9,en-US;q=0.8".parse().unwrap());
373        assert_eq!(HttpHelper::accept_language(&headers).as_deref(), Some("fr-FR"));
374    }
375
376    #[test]
377    fn test_accepts_json() {
378        let mut headers = HeaderMap::new();
379        headers.insert("accept", "application/json".parse().unwrap());
380        assert!(HttpHelper::accepts_json(&headers));
381
382        let headers2 = HeaderMap::new();
383        assert!(!HttpHelper::accepts_json(&headers2));
384    }
385
386    #[test]
387    fn test_is_xhr() {
388        let mut headers = HeaderMap::new();
389        headers.insert("x-requested-with", "XMLHttpRequest".parse().unwrap());
390        assert!(HttpHelper::is_xhr(&headers));
391
392        let headers2 = HeaderMap::new();
393        assert!(!HttpHelper::is_xhr(&headers2));
394    }
395
396    #[test]
397    fn test_referer() {
398        let mut headers = HeaderMap::new();
399        headers.insert("referer", "https://example.com/page".parse().unwrap());
400        assert_eq!(HttpHelper::referer(&headers).as_deref(), Some("https://example.com/page"));
401    }
402}