1use axum::http::HeaderMap;
2use std::net::{IpAddr, SocketAddr};
3
4#[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
15pub struct HttpHelper;
17
18impl HttpHelper {
19 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 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 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/", "Chrome", "Chrome/"),
62 ("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 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 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 pub fn client_ip(headers: &HeaderMap, fallback: IpAddr) -> IpAddr {
129 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 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 pub fn client_ip_from_socket(headers: &HeaderMap, socket: &SocketAddr) -> String {
150 Self::client_ip(headers, socket.ip()).to_string()
151 }
152
153 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 pub fn referer(headers: &HeaderMap) -> Option<String> {
165 Self::get_header(headers, "referer")
166 }
167
168 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 pub fn content_type(headers: &HeaderMap) -> Option<String> {
177 Self::get_header(headers, "content-type")
178 }
179
180 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 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 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 #[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 #[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 #[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}