use crate::{domain::LocationBuilder, Error, Location};
use async_trait::async_trait;
#[cfg(feature = "maxmind")]
pub use maxmind::MaxMindProvider;
#[cfg(feature = "actix-web-v3")]
use actix_web_3::HttpRequest;
#[cfg(feature = "actix-web-v4")]
use actix_web_4::HttpRequest;
#[async_trait(?Send)]
pub trait Provider: Send + Sync {
fn name(&self) -> &str;
async fn get_location(&self, request: &HttpRequest) -> Result<Option<Location>, Error>;
fn expect_country(&self) -> bool {
true
}
fn expect_region(&self) -> bool {
true
}
fn expect_city(&self) -> bool {
true
}
}
pub struct FallbackProvider {
fallback: Location,
}
impl FallbackProvider {
pub fn new(fallback_builder: LocationBuilder) -> Self {
Self {
fallback: fallback_builder
.provider("fallback".to_string())
.finish()
.expect("Location construction bug"),
}
}
}
#[async_trait(?Send)]
impl Provider for FallbackProvider {
fn name(&self) -> &str {
"fallback"
}
async fn get_location(&self, _request: &HttpRequest) -> Result<Option<Location>, Error> {
Ok(Some(self.fallback.clone()))
}
}
#[cfg(feature = "maxmind")]
mod maxmind {
use std::{
net::{IpAddr, SocketAddr},
path::Path,
sync::Arc,
};
use crate::domain::LocationBuilder;
use super::{Error, Location, Provider};
use anyhow::anyhow;
use async_trait::async_trait;
use lazy_static::lazy_static;
use maxminddb::geoip2::City;
#[cfg(feature = "actix-web-v3")]
use actix_web_3::{http::HeaderName, HttpRequest};
#[cfg(feature = "actix-web-v4")]
use actix_web_4::{http::header::HeaderName, HttpRequest};
lazy_static! {
static ref X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
}
#[derive(Clone)]
pub struct MaxMindProvider {
mmdb: Arc<maxminddb::Reader<Vec<u8>>>,
}
impl MaxMindProvider {
pub fn from_path(path: &Path) -> Result<Self, Error> {
Ok(Self {
mmdb: maxminddb::Reader::open_readfile(path)
.map_err(|e| Error::Setup(anyhow!("{}", e)))
.map(Arc::new)?,
})
}
}
#[async_trait(?Send)]
impl Provider for MaxMindProvider {
fn name(&self) -> &str {
"maxmind"
}
async fn get_location(&self, request: &HttpRequest) -> Result<Option<Location>, Error> {
let header = request.headers().get(&*X_FORWARDED_FOR);
let addr = if let Some(header) = header {
let value = header
.to_str()
.map_err(|e| Error::Http(e.into()))?
.split(',')
.next()
.unwrap_or_default()
.trim();
let parsed = value
.parse::<IpAddr>()
.or_else(|_| value.parse::<SocketAddr>().map(|socket| socket.ip()))
.map_err(|e| Error::Http(e.into()))?;
Some(parsed)
} else {
None
};
addr.map(|addr| {
let city = self
.mmdb
.lookup::<City>(addr)
.map_err(|err| Error::Provider(err.into()))?;
let builder: LocationBuilder = (city, "en").into();
builder
.provider("maxmind".to_string())
.finish()
.map_err(|_| Error::Provider(anyhow::anyhow!("Bug while building location")))
})
.transpose()
}
}
}
#[cfg(test)]
pub(crate) mod tests {
#[cfg(not(feature = "actix-web-v4"))]
use actix_web_3::test::TestRequest;
#[cfg(feature = "actix-web-v4")]
use actix_web_4::test::TestRequest;
use super::FallbackProvider;
use crate::{Location, Provider};
#[actix_rt::test]
async fn fallback_works_empty() {
let provider = FallbackProvider::new(Location::build());
let request = TestRequest::default().to_http_request();
let location = provider
.get_location(&request)
.await
.expect("Could not get location")
.expect("Location was none");
assert_eq!(
location,
Location {
country: None,
region: None,
city: None,
dma: None,
provider: "fallback".to_string()
}
)
}
#[actix_rt::test]
async fn fallback_works_full() {
let provider = FallbackProvider::new(
Location::build()
.country("CA".to_string())
.region("BC".to_string())
.city("Burnaby".to_string()),
);
let request = TestRequest::default().to_http_request();
let location = provider
.get_location(&request)
.await
.expect("Could not get location")
.expect("Location was none");
assert_eq!(
location,
Location {
country: Some("CA".to_string()),
region: Some("BC".to_string()),
city: Some("Burnaby".to_string()),
dma: None,
provider: "fallback".to_string()
}
)
}
#[cfg(feature = "maxmind")]
pub(crate) mod maxmind {
use std::path::PathBuf;
use crate::{providers::MaxMindProvider, Error, Location, Provider};
#[cfg(not(feature = "actix-web-v4"))]
use actix_web_3::test::TestRequest;
#[cfg(feature = "actix-web-v4")]
use actix_web_4::test::TestRequest;
pub(crate) const MMDB_LOC: &str = "./GeoLite2-City-Test.mmdb";
pub(crate) const TEST_ADDR_1: &str = "216.160.83.56";
pub(crate) const TEST_ADDR_2: &str = "127.0.0.1";
pub(crate) const TEST_ADDR_3: &str = "216.160.83.56, 127.0.0.1, 10.0.0.1";
pub(crate) const TEST_ADDR_4: &str = "216.160.83.56:31337, 127.0.0.1";
fn test_location() -> Location {
Location::build()
.country("US".to_string())
.region("WA".to_string())
.city("Milton".to_string())
.dma(819)
.provider("maxmind".to_string())
.finish()
.expect("bug when creating location")
}
#[actix_rt::test]
async fn known_ip() {
let provider = MaxMindProvider::from_path(&PathBuf::from(MMDB_LOC))
.expect("could not make maxmind client");
#[cfg(not(feature = "actix-web-v4"))]
let request = TestRequest::default()
.header("X-Forwarded-For", TEST_ADDR_1)
.to_http_request();
#[cfg(feature = "actix-web-v4")]
let request = TestRequest::default()
.insert_header(("X-Forwarded-For", TEST_ADDR_1))
.to_http_request();
let location = provider
.get_location(&request)
.await
.expect("could not get location")
.expect("location was none");
assert_eq!(location, test_location());
}
#[actix_rt::test]
async fn unknown_ip() {
let provider = MaxMindProvider::from_path(&PathBuf::from(MMDB_LOC))
.expect("could not make maxmind client");
#[cfg(not(feature = "actix-web-v4"))]
let request = TestRequest::default()
.header("X-Forwarded-For", TEST_ADDR_2)
.to_http_request();
#[cfg(feature = "actix-web-v4")]
let request = TestRequest::default()
.insert_header(("X-Forwarded-For", TEST_ADDR_2))
.to_http_request();
let location = provider.get_location(&request).await;
assert!(matches!(location, Err(Error::Provider(_))));
}
#[actix_rt::test]
async fn with_proxy_ips() {
let provider = MaxMindProvider::from_path(&PathBuf::from(MMDB_LOC))
.expect("could not make maxmind client");
#[cfg(not(feature = "actix-web-v4"))]
let request = TestRequest::default()
.header("X-Forwarded-For", TEST_ADDR_3)
.to_http_request();
#[cfg(feature = "actix-web-v4")]
let request = TestRequest::default()
.insert_header(("X-Forwarded-For", TEST_ADDR_3))
.to_http_request();
let location = provider
.get_location(&request)
.await
.expect("could not get location")
.expect("location was none");
assert_eq!(location, test_location());
}
#[actix_rt::test]
async fn with_port() {
let provider = MaxMindProvider::from_path(&PathBuf::from(MMDB_LOC))
.expect("could not make maxmind client");
#[cfg(not(feature = "actix-web-v4"))]
let request = TestRequest::default()
.header("X-Forwarded-For", TEST_ADDR_4)
.to_http_request();
#[cfg(feature = "actix-web-v4")]
let request = TestRequest::default()
.insert_header(("X-Forwarded-For", TEST_ADDR_4))
.to_http_request();
let location = provider
.get_location(&request)
.await
.expect("could not get location")
.expect("location was none");
assert_eq!(location, test_location());
}
#[test]
fn expected_info() {
let provider = MaxMindProvider::from_path(&PathBuf::from(MMDB_LOC))
.expect("could not make maxmind client");
assert!(provider.expect_country());
assert!(provider.expect_region());
assert!(provider.expect_city());
}
}
}