Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
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}