Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1use axum::{
2 extract::State,
3 http::{header, HeaderMap, StatusCode},
4 response::{IntoResponse, Response},
5 routing::{get, post},
6 Router,
7};
8use serde::Deserialize;
9use serde_json::Value;
10use std::net::IpAddr;
11use tower_http::cors::{Any, CorsLayer};
12use uuid::Uuid;
13
14use crate::AppState;
15
16pub fn router() -> Router<AppState> {
17 let cors = CorsLayer::new()
18 .allow_origin(Any)
19 .allow_methods(Any)
20 .allow_headers(Any);
21
22 let collect_routes = Router::new()
23 .route("/collect", post(collect).options(options))
24 // /collect/ is a compatibility alias for embeds that hardcoded the
25 // trailing slash. Keep it pointing at the same handlers.
26 .route("/collect/", post(collect).options(options))
27 // Real collector payloads are a few hundred bytes; 16 KB caps what an
28 // abuser can store per event versus axum's 2 MB default.
29 .layer(axum::extract::DefaultBodyLimit::max(16 * 1024))
30 .layer(cors);
31
32 let alias_routes = Router::new()
33 // Stable URL for the collector embed script. Vite content-hashes the
34 // entry, but every embed snippet in the wild hardcodes
35 // /static/collector.js. This aliased handler reads the manifest and
36 // serves the hashed asset by that stable path. CORS does not apply
37 // here; same-origin browsers fetch the script directly.
38 .route("/static/collector.js", get(collector_alias));
39
40 Router::new().merge(collect_routes).merge(alias_routes)
41}
42
43#[derive(Debug, Deserialize)]
44struct CollectBody {
45 #[serde(rename = "collectorId", alias = "collector_id")]
46 collector_id: Option<String>,
47 event: Option<String>,
48 #[serde(default)]
49 data: Value,
50}
51
52pub async fn options(headers: HeaderMap) -> Response {
53 let mut h = HeaderMap::new();
54 let allow_origin = headers
55 .get("origin")
56 .and_then(|v| v.to_str().ok())
57 .unwrap_or("*")
58 .to_string();
59 let allow_headers = headers
60 .get("access-control-request-headers")
61 .and_then(|v| v.to_str().ok())
62 .unwrap_or("Content-Type")
63 .to_string();
64 h.insert("allow", "OPTIONS, POST".parse().unwrap());
65 h.insert("access-control-allow-methods", "OPTIONS, POST".parse().unwrap());
66 h.insert("access-control-allow-headers", allow_headers.parse().unwrap());
67 h.insert("access-control-allow-origin", allow_origin.parse().unwrap());
68 (StatusCode::NO_CONTENT, h).into_response()
69}
70
71pub async fn collect(
72 State(state): State<AppState>,
73 headers: HeaderMap,
74 body: String,
75) -> Response {
76 if body.is_empty() {
77 return StatusCode::BAD_REQUEST.into_response();
78 }
79 let parsed: CollectBody = match serde_json::from_str(&body) {
80 Ok(v) => v,
81 Err(_) => return StatusCode::BAD_REQUEST.into_response(),
82 };
83
84 let Some(collector_id) = parsed.collector_id.as_deref() else {
85 return StatusCode::BAD_REQUEST.into_response();
86 };
87 let Some(event_name) = parsed.event.as_deref() else {
88 return StatusCode::BAD_REQUEST.into_response();
89 };
90 // Each distinct name becomes a dashboard "custom event"; reject absurd
91 // names outright rather than truncating them into colliding aliases.
92 if event_name.chars().count() > 200 {
93 return StatusCode::BAD_REQUEST.into_response();
94 }
95 let Ok(property_id) = Uuid::parse_str(collector_id) else {
96 return StatusCode::BAD_REQUEST.into_response();
97 };
98
99 // Confirm property exists.
100 let exists: Option<(Vec<u8>,)> =
101 sqlx::query_as("SELECT id FROM properties WHERE id = ?")
102 .bind(property_id.as_bytes().to_vec())
103 .fetch_optional(&state.pool)
104 .await
105 .unwrap_or(None);
106 if exists.is_none() {
107 return StatusCode::NOT_FOUND.into_response();
108 }
109
110 let mut data = if parsed.data.is_object() {
111 parsed.data.as_object().unwrap().clone()
112 } else {
113 serde_json::Map::new()
114 };
115
116 // Normalize referrer to bare hostname.
117 if let Some(r) = data.get("referrer").and_then(|v| v.as_str()) {
118 let host = r.split("://").last().unwrap_or(r);
119 let host = host.split('/').next().unwrap_or("");
120 let host = host.to_ascii_lowercase().trim_start_matches("www.").to_string();
121 data.insert("referrer".to_string(), Value::String(host));
122 }
123
124 // GeoIP enrichment for session_start.
125 if event_name == "session_start" {
126 if let Some(ip) = client_ip(&headers) {
127 if !ip.is_loopback() {
128 if let Some(g) = state.geoip.lookup(ip) {
129 if let Some(c) = g.country {
130 data.insert("country".to_string(), Value::String(c));
131 }
132 if let Some(r) = g.region {
133 data.insert("region".to_string(), Value::String(r));
134 }
135 if let Some(c) = g.city {
136 data.insert("city".to_string(), Value::String(c));
137 }
138 if let (Some(lat), Some(lon)) = (g.lat, g.lon) {
139 data.insert(
140 "loc".to_string(),
141 Value::Array(vec![
142 serde_json::json!(lat),
143 serde_json::json!(lon),
144 ]),
145 );
146 }
147 }
148 }
149 }
150 }
151
152 // UA parsing.
153 let ua_string = data
154 .get("user_agent")
155 .and_then(|v| v.as_str())
156 .map(|s| s.to_string())
157 .or_else(|| {
158 headers
159 .get(header::USER_AGENT)
160 .and_then(|v| v.to_str().ok())
161 .map(|s| s.to_string())
162 });
163 if let Some(ua) = &ua_string {
164 let parsed_ua = state.ua.parse(ua);
165 if let Some(p) = parsed_ua.platform.clone() {
166 data.insert("platform".to_string(), Value::String(p));
167 }
168 if let Some(b) = parsed_ua.browser.clone() {
169 data.insert("browser".to_string(), Value::String(b));
170 }
171 if let Some(d) = parsed_ua.device.clone() {
172 data.insert("device".to_string(), Value::String(d));
173 }
174 if parsed_ua.is_bot {
175 data.insert("is_bot".to_string(), Value::Bool(true));
176 if let Some(name) = parsed_ua.bot_name.clone() {
177 data.insert("bot_name".to_string(), Value::String(name));
178 }
179
180 // Bot routing: write to bot_events instead of events.
181 let now = chrono::Utc::now().timestamp_millis();
182 let extra = serde_json::Value::Object(data.clone()).to_string();
183 let _ = sqlx::query(
184 "INSERT INTO bot_events (property_id, event, created_at, bot_name, url, user_agent, country, extra) \
185 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
186 )
187 .bind(property_id.as_bytes().to_vec())
188 .bind(event_name)
189 .bind(now)
190 .bind(parsed_ua.bot_name.as_deref())
191 .bind(data.get("url").and_then(|v| v.as_str()))
192 .bind(ua.as_str())
193 .bind(data.get("country").and_then(|v| v.as_str()))
194 .bind(extra)
195 .execute(&state.pool)
196 .await;
197 return cors_204(&headers);
198 }
199 }
200
201 // Human path: extract hot fields, leave the rest in extra.
202 let now = chrono::Utc::now().timestamp_millis();
203 let take_str = |key: &str, m: &mut serde_json::Map<String, Value>| -> Option<String> {
204 m.remove(key)
205 .and_then(|v| v.as_str().map(|s| s.to_string()).or_else(|| Some(v.to_string())))
206 .filter(|s| !s.is_empty())
207 .map(|s| clamp_chars(s, 2048))
208 };
209 let take_i64 = |key: &str, m: &mut serde_json::Map<String, Value>| -> Option<i64> {
210 m.remove(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
211 };
212 let take_f64 = |key: &str, m: &mut serde_json::Map<String, Value>| -> Option<f64> {
213 m.remove(key).and_then(|v| v.as_f64())
214 };
215
216 let user_id = take_str("user_id", &mut data);
217 let url = take_str("url", &mut data);
218 let title = take_str("title", &mut data);
219 let referrer = take_str("referrer", &mut data);
220 let user_agent = ua_string.clone();
221 data.remove("user_agent");
222 let platform = take_str("platform", &mut data);
223 let browser = take_str("browser", &mut data);
224 let device = take_str("device", &mut data);
225 let screen_width = take_i64("screen_width", &mut data);
226 let screen_height = take_i64("screen_height", &mut data);
227 let country = take_str("country", &mut data);
228 let region = take_str("region", &mut data);
229 let city = take_str("city", &mut data);
230 let (lat, lon) = match data.remove("loc") {
231 Some(Value::Array(arr)) if arr.len() >= 2 => (arr[0].as_f64(), arr[1].as_f64()),
232 _ => (None, None),
233 };
234 let utm_source = take_str("utm_source", &mut data);
235 let utm_medium = take_str("utm_medium", &mut data);
236 let utm_campaign = take_str("utm_campaign", &mut data);
237 let utm_term = take_str("utm_term", &mut data);
238 let utm_content = take_str("utm_content", &mut data);
239 let time_on_page_ms = take_i64("time_on_page", &mut data);
240 let _ = take_f64; // suppress unused
241
242 let extra = if data.is_empty() {
243 "{}".to_string()
244 } else {
245 Value::Object(data).to_string()
246 };
247
248 let _ = sqlx::query(
249 "INSERT INTO events (\
250 property_id, event, created_at, user_id, url, title, referrer, user_agent, \
251 platform, browser, device, screen_width, screen_height, country, region, city, \
252 lat, lon, utm_source, utm_medium, utm_campaign, utm_term, utm_content, \
253 time_on_page_ms, extra\
254 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
255 )
256 .bind(property_id.as_bytes().to_vec())
257 .bind(event_name)
258 .bind(now)
259 .bind(user_id.as_deref())
260 .bind(url.as_deref())
261 .bind(title.as_deref())
262 .bind(referrer.as_deref())
263 .bind(user_agent.as_deref())
264 .bind(platform.as_deref())
265 .bind(browser.as_deref())
266 .bind(device.as_deref())
267 .bind(screen_width)
268 .bind(screen_height)
269 .bind(country.as_deref())
270 .bind(region.as_deref())
271 .bind(city.as_deref())
272 .bind(lat)
273 .bind(lon)
274 .bind(utm_source.as_deref())
275 .bind(utm_medium.as_deref())
276 .bind(utm_campaign.as_deref())
277 .bind(utm_term.as_deref())
278 .bind(utm_content.as_deref())
279 .bind(time_on_page_ms)
280 .bind(extra)
281 .execute(&state.pool)
282 .await;
283
284 cors_204(&headers)
285}
286
287pub async fn collector_alias(State(state): State<AppState>) -> Response {
288 let dist_dir = state.config.root.join("dist");
289 let manifest_path = dist_dir.join(".vite/manifest.json");
290 let manifest_text = match std::fs::read_to_string(&manifest_path) {
291 Ok(t) => t,
292 Err(e) => {
293 tracing::error!("collector manifest read: {e}");
294 return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();
295 }
296 };
297 let manifest: serde_json::Value = match serde_json::from_str(&manifest_text) {
298 Ok(v) => v,
299 Err(e) => {
300 tracing::error!("collector manifest parse: {e}");
301 return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();
302 }
303 };
304 let rel = manifest
305 .get("static_src/collector/index.js")
306 .and_then(|c| c.get("file"))
307 .and_then(|v| v.as_str());
308 let Some(rel) = rel else {
309 tracing::error!("collector entry missing from manifest");
310 return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();
311 };
312 let asset_path = dist_dir.join(rel);
313 let bytes = match std::fs::read(&asset_path) {
314 Ok(b) => b,
315 Err(e) => {
316 tracing::error!("collector read {asset_path:?}: {e}");
317 return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();
318 }
319 };
320 let mut h = HeaderMap::new();
321 h.insert(
322 header::CONTENT_TYPE,
323 "application/javascript; charset=utf-8".parse().unwrap(),
324 );
325 // 5 minutes. Short enough that an asset re-hash propagates within a deploy
326 // window, long enough to absorb burst traffic from the embed snippet.
327 h.insert(
328 header::CACHE_CONTROL,
329 "public, max-age=300, must-revalidate".parse().unwrap(),
330 );
331 (StatusCode::OK, h, bytes).into_response()
332}
333
334fn cors_204(req_headers: &HeaderMap) -> Response {
335 let mut h = HeaderMap::new();
336 let origin = req_headers
337 .get("origin")
338 .and_then(|v| v.to_str().ok())
339 .unwrap_or("*")
340 .to_string();
341 h.insert("access-control-allow-origin", origin.parse().unwrap());
342 (StatusCode::NO_CONTENT, h).into_response()
343}
344
345fn clamp_chars(s: String, max: usize) -> String {
346 if s.chars().count() > max {
347 s.chars().take(max).collect()
348 } else {
349 s
350 }
351}
352
353fn client_ip(headers: &HeaderMap) -> Option<IpAddr> {
354 // Caddy APPENDS the peer address to X-Forwarded-For, so the last entry is
355 // the one our proxy wrote; the first is client-controlled and spoofable.
356 if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
357 if let Some(last) = xff.split(',').next_back() {
358 if let Ok(ip) = last.trim().parse::<IpAddr>() {
359 return Some(ip);
360 }
361 }
362 }
363 if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
364 if let Ok(ip) = real.parse::<IpAddr>() {
365 return Some(ip);
366 }
367 }
368 None
369}