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

5.2 KB · 159 lines · Rust Raw History
  1use chrono::Datelike;
  2use maxminddb::geoip2;
  3use std::net::IpAddr;
  4use std::path::{Path, PathBuf};
  5use std::sync::RwLock;
  6
  7pub struct GeoIp {
  8    path: PathBuf,
  9    reader: RwLock<Option<maxminddb::Reader<Vec<u8>>>>,
 10}
 11
 12#[derive(Debug, Clone, Default)]
 13pub struct GeoLookup {
 14    pub country: Option<String>,
 15    pub region: Option<String>,
 16    pub city: Option<String>,
 17    pub lat: Option<f64>,
 18    pub lon: Option<f64>,
 19}
 20
 21impl GeoIp {
 22    pub fn load(path: &Path) -> Self {
 23        let reader = maxminddb::Reader::open_readfile(path).ok();
 24        if reader.is_some() {
 25            tracing::info!("geoip db loaded from {}", path.display());
 26        } else {
 27            tracing::warn!(
 28                "geoip db missing at {} — country/region enrichment disabled until refresh",
 29                path.display()
 30            );
 31        }
 32        Self {
 33            path: path.to_path_buf(),
 34            reader: RwLock::new(reader),
 35        }
 36    }
 37
 38    pub fn reload(&self) -> bool {
 39        let new_reader = maxminddb::Reader::open_readfile(&self.path).ok();
 40        let ok = new_reader.is_some();
 41        if let Ok(mut w) = self.reader.write() {
 42            *w = new_reader;
 43        }
 44        ok
 45    }
 46
 47    pub fn lookup(&self, ip: IpAddr) -> Option<GeoLookup> {
 48        let guard = self.reader.read().ok()?;
 49        let reader = guard.as_ref()?;
 50        let city: geoip2::City = reader.lookup(ip).ok()?;
 51        let country = city
 52            .country
 53            .as_ref()
 54            .and_then(|c| c.iso_code.as_ref().map(|s| s.to_string()));
 55        let region = city
 56            .subdivisions
 57            .as_ref()
 58            .and_then(|subs| subs.first())
 59            .and_then(|s| {
 60                s.names
 61                    .as_ref()
 62                    .and_then(|n| n.get("en").map(|v| v.to_string()))
 63                    .or_else(|| s.iso_code.map(|s| s.to_string()))
 64            });
 65        let city_name = city
 66            .city
 67            .as_ref()
 68            .and_then(|c| c.names.as_ref())
 69            .and_then(|n| n.get("en").map(|v| v.to_string()));
 70        let (lat, lon) = city
 71            .location
 72            .as_ref()
 73            .map(|l| (l.latitude, l.longitude))
 74            .unwrap_or((None, None));
 75
 76        Some(GeoLookup { country, region, city: city_name, lat, lon })
 77    }
 78}
 79
 80/// Download the latest DB-IP City Lite mmdb to `dest` if missing or older than 30 days.
 81/// CC-BY-4.0, no signup required.
 82///
 83/// DB-IP rolls each month's file on the 1st, but with a few hours of lag.
 84/// Try this month, last month, then two months back so a first-of-the-month
 85/// boot doesn't 404 us into a degraded state.
 86pub async fn ensure_db(dest: &Path) -> anyhow::Result<bool> {
 87    if dest.exists() {
 88        if let Ok(meta) = std::fs::metadata(dest) {
 89            if let Ok(modified) = meta.modified() {
 90                let age = std::time::SystemTime::now()
 91                    .duration_since(modified)
 92                    .unwrap_or_default();
 93                if age.as_secs() < 30 * 24 * 60 * 60 {
 94                    return Ok(false);
 95                }
 96            }
 97        }
 98    }
 99
100    let today = chrono::Utc::now().date_naive();
101    let mut last_err: Option<anyhow::Error> = None;
102    for offset in 0i64..3 {
103        let target = month_offset(today, offset);
104        let url = format!(
105            "https://download.db-ip.com/free/dbip-city-lite-{}-{:02}.mmdb.gz",
106            target.year(),
107            target.month()
108        );
109        match download_gz_to(&url, dest).await {
110            Ok(()) => {
111                tracing::info!("downloaded geoip db from {url}");
112                return Ok(true);
113            }
114            Err(e) => {
115                tracing::warn!(
116                    "geoip download failed for {}-{:02}: {e}",
117                    target.year(),
118                    target.month()
119                );
120                last_err = Some(e);
121            }
122        }
123    }
124    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("geoip download failed (no candidates)")))
125}
126
127/// Return the first-of-the-month for `today` shifted back `offset` months.
128fn month_offset(today: chrono::NaiveDate, offset: i64) -> chrono::NaiveDate {
129    let mut y = today.year();
130    let mut m = today.month() as i64 - offset;
131    while m <= 0 {
132        m += 12;
133        y -= 1;
134    }
135    chrono::NaiveDate::from_ymd_opt(y, m as u32, 1).unwrap_or(today)
136}
137
138async fn download_gz_to(url: &str, dest: &Path) -> anyhow::Result<()> {
139    let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?;
140    use std::io::Read;
141    let mut decoder = flate2::read::GzDecoder::new(&bytes[..]);
142    let mut out = Vec::new();
143    decoder.read_to_end(&mut out)?;
144    if let Some(parent) = dest.parent() {
145        std::fs::create_dir_all(parent)?;
146    }
147    // Write to a temp file, prove the mmdb actually opens, then rename into
148    // place: a truncated download must never clobber a good db, because the
149    // mtime freshness check would then skip retries for 30 days.
150    let tmp = dest.with_extension("mmdb.tmp");
151    std::fs::write(&tmp, out)?;
152    if let Err(e) = maxminddb::Reader::open_readfile(&tmp) {
153        let _ = std::fs::remove_file(&tmp);
154        return Err(anyhow::anyhow!("downloaded mmdb failed validation: {e}"));
155    }
156    std::fs::rename(&tmp, dest)?;
157    Ok(())
158}