Analytics!

This commit is contained in:
Lyle Mantooth 2023-05-20 22:42:03 -04:00
parent 485870a6b1
commit a062934369
Signed by: IslandUsurper
GPG key ID: 6DB52EAE123A5789
3 changed files with 112 additions and 9 deletions

View file

@ -1,6 +1,6 @@
[package] [package]
name = "locat" name = "locat"
version = "0.3.1" version = "0.4.0"
edition = "2021" edition = "2021"
publish = ["menteeth"] publish = ["menteeth"]
@ -8,4 +8,5 @@ publish = ["menteeth"]
[dependencies] [dependencies]
maxminddb = "0.23" maxminddb = "0.23"
rusqlite = "0.28"
thiserror = "1" thiserror = "1"

View file

@ -14,7 +14,7 @@
{ {
defaultPackage = naersk-lib.buildPackage ./.; defaultPackage = naersk-lib.buildPackage ./.;
devShell = with pkgs; mkShell { devShell = with pkgs; mkShell {
buildInputs = [ cargo rustc rustfmt pre-commit rustPackages.clippy ]; buildInputs = [ cargo rustc rustfmt pre-commit rustPackages.clippy sqlite];
RUST_SRC_PATH = rustPlatform.rustLibSrc; RUST_SRC_PATH = rustPlatform.rustLibSrc;
}; };
}); });

View file

@ -2,35 +2,137 @@ use std::net::IpAddr;
/// Allows geo-locating IPs and keeps analytics. /// Allows geo-locating IPs and keeps analytics.
pub struct Locat { pub struct Locat {
geoip: maxminddb::Reader<Vec<u8>>, reader: maxminddb::Reader<Vec<u8>>,
analytics: Db
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("maxminddb error: {0}")] #[error("maxminddb error: {0}")]
MaxMindDb(#[from] maxminddb::MaxMindDBError), MaxMindDb(#[from] maxminddb::MaxMindDBError),
#[error("rusqlite error: {0}")]
Rusqlite(#[from] rusqlite::Error),
} }
impl Locat { impl Locat {
pub fn new(geoip_country_db_path: &str, _analytics_db_path: &str) -> Result<Self, Error> { pub fn new(geoip_country_db_path: &str, analytics_db_path: &str) -> Result<Self, Error> {
// Todo: create analytics db. // Todo: create analytics db.
Ok(Self { Ok(Self {
geoip: maxminddb::Reader::open_readfile(geoip_country_db_path)?, reader: maxminddb::Reader::open_readfile(geoip_country_db_path)?,
analytics: Db {
path: analytics_db_path.to_string(),
},
}) })
} }
/// Converts an address to an ISO 3166-1 alpha-2 country code. /// Converts an address to an ISO 3166-1 alpha-2 country code.
pub async fn ip_to_iso_code(&self, addr: IpAddr) -> Option<&str> { pub async fn ip_to_iso_code(&self, addr: IpAddr) -> Option<&str> {
self.geoip let iso_code = self
.reader
.lookup::<maxminddb::geoip2::Country>(addr) .lookup::<maxminddb::geoip2::Country>(addr)
.ok()? .ok()?
.country? .country?
.iso_code .iso_code?;
if let Err(e) = self.analytics.increment(iso_code) {
eprintln!("Could not increment analytic: {e}");
}
Some(iso_code)
} }
/// Returns a map of country codes to number of requests. /// Returns a map of country codes to number of requests.
pub async fn get_analytics(&self) -> Vec<(String, u64)> { pub async fn get_analytics(&self) -> Result<Vec<(String, u64)>, Error> {
Default::default() Ok(self.analytics.list()?)
}
}
struct Db {
path: String,
}
impl Db {
fn list(&self) -> Result<Vec<(String, u64)>, rusqlite::Error> {
let conn = self.get_conn()?;
let mut stmt = conn.prepare("SELECT iso_code, count FROM analytics")?;
let mut rows = stmt.query([])?;
let mut analytics = Vec::new();
while let Some(row) = rows.next()? {
let iso_code: String = row.get(0)?;
let count: u64 = row.get(1)?;
analytics.push((iso_code, count));
}
Ok(analytics)
}
fn increment(&self, iso_code: &str) -> Result<(), rusqlite::Error> {
let conn = self.get_conn().unwrap();
let mut stmt = conn
.prepare("INSERT INTO analytics (iso_code, count) VALUES (?, 1) ON CONFLICT (iso_code) DO UPDATE SET count = count + 1")?;
stmt.execute([iso_code])?;
Ok(())
}
fn get_conn(&self) -> Result<rusqlite::Connection, rusqlite::Error> {
let conn = rusqlite::Connection::open(&self.path).unwrap();
self.migrate(&conn)?;
Ok(conn)
}
fn migrate(&self, conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
// create analytics table.
conn.execute(
"CREATE TABLE IF NOT EXISTS analytics (
iso_code TEXT PRIMARY KEY,
count INTEGER NOT NULL
)",
[],
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::Db;
struct RemoveOnDrop {
path: String,
}
impl Drop for RemoveOnDrop {
fn drop(&mut self) {
_ = std::fs::remove_file(&self.path);
}
}
#[test]
fn test_db() {
let db = Db {
path: "/tmp/locat-test.db".to_string(),
};
let _remove_on_drop = RemoveOnDrop {
path: db.path.clone(),
};
let analytics = db.list().unwrap();
assert_eq!(analytics.len(), 0);
db.increment("US").unwrap();
let analytics = db.list().unwrap();
assert_eq!(analytics.len(), 1);
db.increment("US").unwrap();
db.increment("FR").unwrap();
let analytics = db.list().unwrap();
assert_eq!(analytics.len(), 2);
// contains US at count 2
assert!(analytics.contains(&("US".to_string(), 2)));
// contains FR at count 1
assert!(analytics.contains(&("FR".to_string(), 1)));
// does not contain DE
assert!(!analytics.contains(&("DE".to_string(), 0)));
} }
} }