repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

13.4 KB · 370 lines · Rust Raw History
  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}