1use chrono::{Local, NaiveDate, TimeDelta};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::sync::{Arc, OnceLock, RwLock};
12
13static ANALYTICS: OnceLock<Arc<RwLock<AnalyticsTracker>>> = OnceLock::new();
14
15#[derive(Debug, Default, Serialize, Deserialize)]
16pub struct AnalyticsTracker {
17 days: HashMap<String, DayData>,
18 hourly: VecDeque<HourBucket>,
19}
20
21#[derive(Debug, Default, Clone, Serialize, Deserialize)]
22struct DayData {
23 total_views: u64,
24 total_downloads: u64,
25 unique_ips: HashSet<String>,
26 page_counts: HashMap<String, u64>,
27 download_counts: HashMap<String, u64>,
28 subdomain_views: HashMap<String, u64>,
29 subdomain_ips: HashMap<String, HashSet<String>>,
30}
31
32#[derive(Debug, Default, Clone, Serialize, Deserialize)]
33struct HourBucket {
34 hour: String,
35 views: u64,
36 unique_ips: HashSet<String>,
37}
38
39pub fn get_analytics() -> &'static Arc<RwLock<AnalyticsTracker>> {
42 ANALYTICS.get_or_init(|| {
43 let tracker = load_from_file().unwrap_or_default();
44 let arc = Arc::new(RwLock::new(tracker));
45
46 let arc_clone = arc.clone();
47 std::thread::spawn(move || {
48 let rt = tokio::runtime::Builder::new_current_thread()
49 .enable_all()
50 .build()
51 .expect("analytics save runtime");
52 rt.block_on(async move {
53 loop {
54 tokio::time::sleep(tokio::time::Duration::from_secs(300)).await;
55 if let Ok(tracker) = arc_clone.read() {
56 if let Err(e) = save_to_file(&tracker) {
57 log::error!("Failed to save analytics: {}", e);
58 }
59 }
60 }
61 });
62 });
63
64 log::info!("Analytics tracker initialized");
65 arc
66 })
67}
68
69pub fn track_request(subdomain: &str, path: &str, ip: &str, user_agent: &str) {
72 if !is_trackable_request(path, user_agent) {
73 return;
74 }
75
76 let analytics = get_analytics();
77 let mut tracker = match analytics.write() {
78 Ok(t) => t,
79 Err(_) => return,
80 };
81
82 let now = Local::now();
83 let date = now.format("%Y-%m-%d").to_string();
84 let hour_key = now.format("%Y-%m-%dT%H:00").to_string();
85 let ip_hash = hash_ip(ip);
86 let subdomain_key = if subdomain.is_empty() {
87 "direct"
88 } else {
89 subdomain
90 };
91
92 let clean_path = path.split('?').next().unwrap_or(path);
93
94 let day = tracker.days.entry(date).or_default();
96 day.total_views += 1;
97 day.unique_ips.insert(ip_hash.clone());
98 *day.page_counts.entry(clean_path.to_string()).or_default() += 1;
99 *day.subdomain_views
100 .entry(subdomain_key.to_string())
101 .or_default() += 1;
102 day.subdomain_ips
103 .entry(subdomain_key.to_string())
104 .or_default()
105 .insert(ip_hash.clone());
106
107 if is_download(clean_path) {
108 day.total_downloads += 1;
109 *day.download_counts
110 .entry(clean_path.to_string())
111 .or_default() += 1;
112 }
113
114 if let Some(bucket) = tracker.hourly.back_mut() {
116 if bucket.hour == hour_key {
117 bucket.views += 1;
118 bucket.unique_ips.insert(ip_hash);
119 return;
120 }
121 }
122 let mut ips = HashSet::new();
123 ips.insert(ip_hash);
124 tracker.hourly.push_back(HourBucket {
125 hour: hour_key,
126 views: 1,
127 unique_ips: ips,
128 });
129 while tracker.hourly.len() > 48 {
130 tracker.hourly.pop_front();
131 }
132}
133
134fn is_trackable_request(path: &str, user_agent: &str) -> bool {
135 let path_lower = path.to_lowercase();
136 let clean = path_lower.split('?').next().unwrap_or(&path_lower);
137
138 if matches!(
140 clean,
141 "/api/health"
142 | "/api/status"
143 | "/api/metrics"
144 | "/api/analytics"
145 | "/api/analytics/dashboard"
146 | "/api/logs"
147 | "/api/logs/raw"
148 | "/api/ping"
149 ) {
150 return false;
151 }
152
153 if clean.starts_with("/.rss/")
155 || clean == "/rss.js"
156 || clean.starts_with("/ws/")
157 || clean.starts_with("/.well-known/")
158 || clean == "/favicon.ico"
159 {
160 return false;
161 }
162
163 let ua = user_agent.to_lowercase();
165 if ua.contains("bot")
166 || ua.contains("crawler")
167 || ua.contains("spider")
168 || ua.contains("curl")
169 || ua.contains("wget")
170 || ua.contains("python-requests")
171 || ua.contains("go-http-client")
172 || ua.contains("headlesschrome")
173 || ua.contains("phantomjs")
174 {
175 return false;
176 }
177
178 true
179}
180
181fn is_download(path: &str) -> bool {
182 let lower = path.to_lowercase();
183 lower.ends_with(".zip")
184 || lower.ends_with(".tar.gz")
185 || lower.ends_with(".exe")
186 || lower.ends_with(".dmg")
187 || lower.ends_with(".deb")
188 || lower.ends_with(".rpm")
189 || lower.ends_with(".msi")
190 || lower.ends_with(".pkg")
191 || lower.ends_with(".appimage")
192 || lower.ends_with(".bin")
193}
194
195fn hash_ip(ip: &str) -> String {
196 use std::collections::hash_map::DefaultHasher;
197 use std::hash::{Hash, Hasher};
198 let mut hasher = DefaultHasher::new();
199 ip.hash(&mut hasher);
200 format!("{:x}", hasher.finish())
201}
202
203pub fn get_summary() -> serde_json::Value {
205 let analytics = get_analytics();
206 let tracker = match analytics.read() {
207 Ok(t) => t,
208 Err(_) => return json!({"error": "lock poisoned"}),
209 };
210
211 let now = Local::now();
212 let today = now.format("%Y-%m-%d").to_string();
213
214 let today_data = build_period_summary(&tracker, &today, 1);
215 let week_data = build_period_summary(&tracker, &today, 7);
216 let month_data = build_period_summary(&tracker, &today, 30);
217
218 let cutoff = (now - TimeDelta::hours(24))
219 .format("%Y-%m-%dT%H:00")
220 .to_string();
221 let hourly: Vec<serde_json::Value> = tracker
222 .hourly
223 .iter()
224 .filter(|b| b.hour >= cutoff)
225 .map(|b| {
226 json!({
227 "hour": b.hour,
228 "views": b.views,
229 "unique": b.unique_ips.len()
230 })
231 })
232 .collect();
233
234 let by_subdomain = build_subdomain_summary(&tracker, &today, 7);
235
236 json!({
237 "today": today_data,
238 "last_7_days": week_data,
239 "last_30_days": month_data,
240 "hourly_traffic": hourly,
241 "by_subdomain": by_subdomain,
242 })
243}
244
245fn build_period_summary(
246 tracker: &AnalyticsTracker,
247 today: &str,
248 days: i64,
249) -> serde_json::Value {
250 let today_date = NaiveDate::parse_from_str(today, "%Y-%m-%d")
251 .unwrap_or_else(|_| Local::now().date_naive());
252
253 let mut total_views = 0u64;
254 let mut total_downloads = 0u64;
255 let mut all_ips: HashSet<String> = HashSet::new();
256 let mut page_totals: HashMap<String, u64> = HashMap::new();
257 let mut download_totals: HashMap<String, u64> = HashMap::new();
258
259 for i in 0..days {
260 let date = (today_date - TimeDelta::days(i))
261 .format("%Y-%m-%d")
262 .to_string();
263 if let Some(day) = tracker.days.get(&date) {
264 total_views += day.total_views;
265 total_downloads += day.total_downloads;
266 all_ips.extend(day.unique_ips.iter().cloned());
267 for (path, count) in &day.page_counts {
268 *page_totals.entry(path.clone()).or_default() += count;
269 }
270 for (file, count) in &day.download_counts {
271 *download_totals.entry(file.clone()).or_default() += count;
272 }
273 }
274 }
275
276 let mut pages: Vec<_> = page_totals.into_iter().collect();
277 pages.sort_by(|a, b| b.1.cmp(&a.1));
278 let top_pages: Vec<serde_json::Value> = pages
279 .into_iter()
280 .take(10)
281 .map(|(path, views)| json!({"path": path, "views": views}))
282 .collect();
283
284 let mut downloads: Vec<_> = download_totals.into_iter().collect();
285 downloads.sort_by(|a, b| b.1.cmp(&a.1));
286 let top_downloads: Vec<serde_json::Value> = downloads
287 .into_iter()
288 .take(10)
289 .map(|(file, count)| json!({"file": file, "count": count}))
290 .collect();
291
292 json!({
293 "page_views": total_views,
294 "unique_visitors": all_ips.len(),
295 "downloads": total_downloads,
296 "top_pages": top_pages,
297 "top_downloads": top_downloads,
298 })
299}
300
301fn build_subdomain_summary(
302 tracker: &AnalyticsTracker,
303 today: &str,
304 days: i64,
305) -> serde_json::Value {
306 let today_date = NaiveDate::parse_from_str(today, "%Y-%m-%d")
307 .unwrap_or_else(|_| Local::now().date_naive());
308
309 let mut views: HashMap<String, u64> = HashMap::new();
310 let mut ips: HashMap<String, HashSet<String>> = HashMap::new();
311
312 for i in 0..days {
313 let date = (today_date - TimeDelta::days(i))
314 .format("%Y-%m-%d")
315 .to_string();
316 if let Some(day) = tracker.days.get(&date) {
317 for (sub, v) in &day.subdomain_views {
318 *views.entry(sub.clone()).or_default() += v;
319 }
320 for (sub, ip_set) in &day.subdomain_ips {
321 ips.entry(sub.clone())
322 .or_default()
323 .extend(ip_set.iter().cloned());
324 }
325 }
326 }
327
328 let mut map = serde_json::Map::new();
329 for (sub, v) in &views {
330 let unique = ips.get(sub).map(|s| s.len()).unwrap_or(0);
331 map.insert(sub.clone(), json!({"views": v, "unique": unique}));
332 }
333 serde_json::Value::Object(map)
334}
335
336fn get_analytics_path() -> std::path::PathBuf {
337 crate::core::helpers::get_base_dir()
338 .map(|b| b.join(".rss").join("analytics.json"))
339 .unwrap_or_else(|_| std::path::PathBuf::from(".rss/analytics.json"))
340}
341
342fn save_to_file(tracker: &AnalyticsTracker) -> Result<(), Box<dyn std::error::Error>> {
343 let path = get_analytics_path();
344 if let Some(parent) = path.parent() {
345 std::fs::create_dir_all(parent)?;
346 }
347 let json = serde_json::to_string(tracker)?;
348 std::fs::write(&path, json)?;
349 log::debug!("Analytics saved to {:?}", path);
350 Ok(())
351}
352
353fn load_from_file() -> Option<AnalyticsTracker> {
354 let path = get_analytics_path();
355 let content = std::fs::read_to_string(&path).ok()?;
356 serde_json::from_str(&content).ok()
357}
358
359pub fn save_analytics_on_shutdown() {
361 if let Some(analytics) = ANALYTICS.get() {
362 if let Ok(mut tracker) = analytics.write() {
363 prune_old_data(&mut tracker);
364 if let Err(e) = save_to_file(&tracker) {
365 log::error!("Failed to save analytics on shutdown: {}", e);
366 } else {
367 log::info!("Analytics saved on shutdown");
368 }
369 }
370 }
371}
372
373fn prune_old_data(tracker: &mut AnalyticsTracker) {
374 let cutoff = (Local::now() - TimeDelta::days(60))
375 .format("%Y-%m-%d")
376 .to_string();
377 tracker
378 .days
379 .retain(|date, _| date.as_str() >= cutoff.as_str());
380}
381
382pub const DASHBOARD_HTML: &str = r#"<!DOCTYPE html>
385<html lang="en">
386<head>
387<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
388<title>Analytics - Rush Sync Server</title>
389<link rel="icon" href="/.rss/favicon.svg" type="image/svg+xml">
390<style>
391*{margin:0;padding:0;box-sizing:border-box}
392body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e4e4ef;min-height:100vh}
393.container{max-width:1200px;margin:0 auto;padding:24px}
394.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:32px;flex-wrap:wrap;gap:12px}
395.header h1{font-size:26px;font-weight:700;letter-spacing:-0.5px}
396.header h1 span{color:#6c63ff}
397.header-right{display:flex;align-items:center;gap:16px}
398.live-dot{width:8px;height:8px;border-radius:50%;background:#4ade80;animation:pulse 2s ease-in-out infinite}
399@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(74,222,128,0.4)}50%{box-shadow:0 0 0 6px rgba(74,222,128,0)}}
400.live-label{font-size:12px;color:#4ade80;display:flex;align-items:center;gap:6px}
401.back{color:#6c63ff;text-decoration:none;font-size:14px}
402.back:hover{text-decoration:underline}
403.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}
404.card{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:20px;transition:border-color 0.2s}
405.card:hover{border-color:#3a3a4a}
406.card .lbl{font-size:11px;color:#8888a0;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
407.card .val{font-size:32px;font-weight:700;line-height:1.1}
408.card .sub{font-size:12px;color:#555;margin-top:6px}
409.card .val.purple{color:#6c63ff}
410.card .val.green{color:#4ade80}
411.card .val.blue{color:#22d3ee}
412.card .val.orange{color:#fb923c}
413.tabs{display:flex;gap:8px;margin-bottom:20px}
414.tab{padding:8px 18px;border-radius:8px;background:#14141f;border:1px solid #2a2a3a;color:#8888a0;cursor:pointer;font-size:13px;font-weight:500;transition:all 0.2s}
415.tab:hover{border-color:#6c63ff;color:#c0c0d0}
416.tab.active{background:#6c63ff;color:#fff;border-color:#6c63ff}
417.section{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:24px;margin-bottom:16px}
418.section h2{font-size:14px;margin-bottom:16px;font-weight:600;color:#8888a0;text-transform:uppercase;letter-spacing:0.5px}
419.chart{display:flex;align-items:flex-end;gap:3px;height:160px;padding:0 0 28px 0;position:relative}
420.bar-w{flex:1;display:flex;flex-direction:column;align-items:center;position:relative;min-width:0}
421.bar{width:100%;background:linear-gradient(180deg,#6c63ff,#4a43cc);border-radius:4px 4px 0 0;min-height:1px;transition:height 0.4s ease;cursor:pointer}
422.bar:hover{background:linear-gradient(180deg,#8b83ff,#6c63ff)}
423.bar-lbl{font-size:9px;color:#555566;position:absolute;bottom:-22px;white-space:nowrap}
424.tooltip{display:none;position:absolute;top:-44px;left:50%;transform:translateX(-50%);background:#2a2a3a;color:#e4e4ef;padding:6px 10px;border-radius:6px;font-size:11px;white-space:nowrap;z-index:10;box-shadow:0 4px 12px rgba(0,0,0,0.3)}
425.tooltip::after{content:'';position:absolute;bottom:-4px;left:50%;transform:translateX(-50%);border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #2a2a3a}
426.bar-w:hover .tooltip{display:block}
427.chart-empty{display:flex;align-items:flex-end;gap:3px;height:160px;padding-bottom:28px}
428.chart-empty .bar-w{flex:1}.chart-empty .bar{background:#1a1a2a;height:20%;border-radius:4px 4px 0 0}
429.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
430@media(max-width:768px){.grid{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}.chart{height:120px}}
431table{width:100%;border-collapse:collapse}
432th{text-align:left;font-size:10px;color:#555566;text-transform:uppercase;letter-spacing:1px;padding:8px 0;border-bottom:1px solid #2a2a3a}
433td{padding:10px 0;font-size:13px;border-bottom:1px solid #1a1a2a}
434td:last-child{text-align:right;font-weight:600;color:#6c63ff;font-family:'SF Mono',monospace;font-size:12px}
435.path-cell{font-family:'SF Mono',monospace;font-size:12px;color:#c0c0d0;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
436.sub-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
437.sub-card{background:#1a1a2a;border:1px solid #252535;border-radius:10px;padding:16px;transition:border-color 0.2s}
438.sub-card:hover{border-color:#3a3a4a}
439.sub-card .name{font-weight:600;margin-bottom:6px;color:#e4e4ef;font-size:14px}
440.sub-card .stats{font-size:12px;color:#8888a0;line-height:1.5}
441.sub-card .stats span{color:#6c63ff;font-weight:600}
442.empty{color:#555;font-style:italic;font-size:13px;padding:20px;text-align:center}
443.footer{text-align:center;font-size:11px;color:#444;padding:20px;display:flex;justify-content:center;align-items:center;gap:8px}
444.footer .refresh-dot{width:6px;height:6px;border-radius:50%;background:#333;animation:none}
445.footer .refresh-dot.active{background:#4ade80;animation:pulse 2s ease-in-out infinite}
446</style>
447</head>
448<body>
449<div class="container">
450<div class="header">
451<h1>Analytics <span>Dashboard</span></h1>
452<div class="header-right">
453<div class="live-label"><div class="live-dot"></div>Live</div>
454<a href="/" class="back">← Back to site</a>
455</div>
456</div>
457<div class="cards" id="cards"></div>
458<div class="tabs" id="tabs">
459<div class="tab active" data-p="today">Today</div>
460<div class="tab" data-p="last_7_days">7 Days</div>
461<div class="tab" data-p="last_30_days">30 Days</div>
462</div>
463<div class="section"><h2>Hourly Traffic (Last 24h)</h2><div class="chart" id="chart"></div></div>
464<div class="grid">
465<div class="section"><h2>Top Pages</h2><div id="pages"></div></div>
466<div class="section"><h2>Downloads</h2><div id="downloads"></div></div>
467</div>
468<div class="section"><h2>Traffic by Subdomain</h2><div class="sub-grid" id="subs"></div></div>
469<div class="footer"><div class="refresh-dot active" id="rdot"></div><span id="foot">Loading...</span></div>
470</div>
471<script>
472var D=__ANALYTICS_DATA__;
473var P='today';
474var refreshTimer=30;
475document.querySelectorAll('.tab').forEach(function(t){t.addEventListener('click',function(){document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});t.classList.add('active');P=t.dataset.p;render()})});
476function render(){var p=D[P]||D.today||{};
477var views=p.page_views||0;var uniq=p.unique_visitors||0;var dls=p.downloads||0;
478var vpu=uniq>0?Math.round(views/uniq):0;
479document.getElementById('cards').innerHTML=
480'<div class="card"><div class="lbl">Page Views</div><div class="val purple">'+fmt(views)+'</div><div class="sub">Total tracked requests</div></div>'+
481'<div class="card"><div class="lbl">Unique Visitors</div><div class="val green">'+fmt(uniq)+'</div><div class="sub">By unique IP address</div></div>'+
482'<div class="card"><div class="lbl">Downloads</div><div class="val blue">'+fmt(dls)+'</div><div class="sub">Binary downloads</div></div>'+
483'<div class="card"><div class="lbl">Views / Visitor</div><div class="val orange">'+fmt(vpu)+'</div><div class="sub">Avg. engagement</div></div>';
484renderChart();renderPages(p);renderDownloads(p);renderSubs()}
485function renderChart(){var h=D.hourly_traffic||[];
486var el=document.getElementById('chart');
487if(h.length===0){el.innerHTML='<div class="empty">No hourly data yet</div>';return}
488var allHours=[];var hmap={};
489h.forEach(function(x){var hr=x.hour;hmap[hr]=x});
490var now=new Date();for(var i=23;i>=0;i--){var d=new Date(now.getTime()-i*3600000);var key=d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'T'+pad(d.getHours())+':00';allHours.push(key)}
491var mx=1;allHours.forEach(function(k){var v=hmap[k];if(v&&v.views>mx)mx=v.views});
492el.innerHTML=allHours.map(function(k){var v=hmap[k]||{views:0,unique:0};var pct=mx>0?(v.views/mx)*100:0;var hr=k.split('T')[1].replace(':00','h');var barH=v.views>0?Math.max(pct,3):0;return '<div class="bar-w"><div class="bar" style="height:'+barH+'%"></div><div class="tooltip">'+fmt(v.views)+' views · '+fmt(v.unique)+' unique</div><div class="bar-lbl">'+hr+'</div></div>'}).join('')}
493function renderPages(p){var pg=p.top_pages||[];var el=document.getElementById('pages');
494if(pg.length===0){el.innerHTML='<div class="empty">No page views yet</div>';return}
495el.innerHTML='<table><tr><th>Path</th><th>Views</th></tr>'+pg.map(function(x){return '<tr><td class="path-cell" title="'+esc(x.path)+'">'+esc(x.path)+'</td><td>'+fmt(x.views)+'</td></tr>'}).join('')+'</table>'}
496function renderDownloads(p){var dl=p.top_downloads||[];var el=document.getElementById('downloads');
497if(dl.length===0){el.innerHTML='<div class="empty">No downloads tracked yet</div>';return}
498el.innerHTML='<table><tr><th>File</th><th>Count</th></tr>'+dl.map(function(x){var name=x.file.split('/').pop();return '<tr><td class="path-cell" title="'+esc(x.file)+'">'+esc(name)+'</td><td>'+fmt(x.count)+'</td></tr>'}).join('')+'</table>'}
499function renderSubs(){var sb=D.by_subdomain||{};var sk=Object.keys(sb);var el=document.getElementById('subs');
500if(sk.length===0){el.innerHTML='<div class="empty">No subdomain data</div>';return}
501sk.sort(function(a,b){return (sb[b].views||0)-(sb[a].views||0)});
502el.innerHTML=sk.map(function(s){var d=sb[s];return '<div class="sub-card"><div class="name">'+esc(s)+'</div><div class="stats"><span>'+fmt(d.views)+'</span> views · <span>'+fmt(d.unique)+'</span> unique visitors</div></div>'}).join('')}
503function fmt(n){return (n||0).toLocaleString()}
504function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
505function pad(n){return n<10?'0'+n:''+n}
506render();
507setInterval(function(){refreshTimer--;if(refreshTimer<=0){location.reload()}document.getElementById('foot').textContent='Updated '+new Date().toLocaleTimeString()+' \u00b7 refresh in '+refreshTimer+'s'},1000);
508document.getElementById('foot').textContent='Updated '+new Date().toLocaleTimeString()+' \u00b7 refresh in 30s';
509</script>
510</body></html>"#;
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn test_is_trackable_filters_health() {
518 assert!(!is_trackable_request("/api/health", "Mozilla/5.0"));
519 assert!(!is_trackable_request("/api/status", "Mozilla/5.0"));
520 assert!(!is_trackable_request("/api/metrics", "Mozilla/5.0"));
521 }
522
523 #[test]
524 fn test_is_trackable_filters_internal() {
525 assert!(!is_trackable_request("/.rss/style.css", "Mozilla/5.0"));
526 assert!(!is_trackable_request("/rss.js", "Mozilla/5.0"));
527 assert!(!is_trackable_request("/ws/hot-reload", "Mozilla/5.0"));
528 assert!(!is_trackable_request("/.well-known/acme-challenge/xxx", "Mozilla/5.0"));
529 }
530
531 #[test]
532 fn test_is_trackable_filters_bots() {
533 assert!(!is_trackable_request("/", "Googlebot/2.1"));
534 assert!(!is_trackable_request("/", "curl/7.68.0"));
535 assert!(!is_trackable_request("/", "Python-requests/2.28"));
536 }
537
538 #[test]
539 fn test_is_trackable_allows_real_requests() {
540 assert!(is_trackable_request("/", "Mozilla/5.0 (Macintosh)"));
541 assert!(is_trackable_request("/docs", "Mozilla/5.0"));
542 assert!(is_trackable_request("/about", "Safari/537.36"));
543 }
544
545 #[test]
546 fn test_is_download() {
547 assert!(is_download("/releases/app.zip"));
548 assert!(is_download("/releases/app.tar.gz"));
549 assert!(is_download("/releases/app.exe"));
550 assert!(is_download("/releases/app.dmg"));
551 assert!(is_download("/releases/app.AppImage"));
552 assert!(is_download("/downloads/rush-sync-linux-amd64.bin"));
553 assert!(!is_download("/index.html"));
554 assert!(!is_download("/api/status"));
555 }
556
557 #[test]
558 fn test_hash_ip_deterministic() {
559 let h1 = hash_ip("192.168.1.1");
560 let h2 = hash_ip("192.168.1.1");
561 assert_eq!(h1, h2);
562 assert_ne!(hash_ip("192.168.1.1"), hash_ip("10.0.0.1"));
563 }
564}