Skip to main content

rustenium_identity/
tz.rs

1use crate::error::IdentityError;
2
3/// What the exit IP says about where the browser is sitting.
4///
5/// Timezone and language are checked *against the IP*, not just for internal
6/// consistency — a Dutch exit node reporting `en-US` and `America/New_York` is the
7/// mismatch these suites exist to find. Both are therefore derived from the same
8/// lookup rather than carried in the persona.
9pub struct Geo {
10    pub timezone: String,
11    pub country_code: Option<String>,
12}
13
14/// Resolve timezone and country: use the explicit timezone override if provided,
15/// otherwise query ip-api.com for the (possibly proxied) exit IP.
16pub async fn resolve_geo(
17    explicit_tz: Option<&str>,
18    proxy: Option<&str>,
19) -> Result<Geo, IdentityError> {
20    let fetched = fetch_from_ipapi(proxy).await;
21
22    match (explicit_tz, fetched) {
23        // An explicit timezone still benefits from the country lookup, but must not
24        // fail the launch if that lookup is unavailable.
25        (Some(tz), Ok(geo)) => Ok(Geo {
26            timezone: tz.to_string(),
27            country_code: geo.country_code,
28        }),
29        (Some(tz), Err(_)) => Ok(Geo {
30            timezone: tz.to_string(),
31            country_code: None,
32        }),
33        (None, result) => result,
34    }
35}
36
37/// Query ip-api.com for the IANA timezone and ISO country of the current IP
38/// (optionally routed through a proxy).
39async fn fetch_from_ipapi(proxy: Option<&str>) -> Result<Geo, IdentityError> {
40    let mut builder = reqwest::Client::builder();
41    if let Some(proxy_url) = proxy {
42        builder = builder.proxy(
43            reqwest::Proxy::all(proxy_url)
44                .map_err(|e| IdentityError::TimezoneError(format!("invalid proxy: {e}")))?,
45        );
46    }
47    let client = builder
48        .build()
49        .map_err(|e| IdentityError::TimezoneError(format!("http client error: {e}")))?;
50
51    let resp: serde_json::Value = client
52        .get("http://ip-api.com/json/?fields=timezone,countryCode")
53        .send()
54        .await
55        .map_err(|e| IdentityError::TimezoneError(format!("ip-api request failed: {e}")))?
56        .json()
57        .await
58        .map_err(|e| IdentityError::TimezoneError(format!("ip-api parse failed: {e}")))?;
59
60    let timezone = resp
61        .get("timezone")
62        .and_then(|v| v.as_str())
63        .map(|s| s.to_string())
64        .ok_or_else(|| IdentityError::TimezoneError("ip-api returned no timezone".into()))?;
65
66    Ok(Geo {
67        timezone,
68        country_code: resp
69            .get("countryCode")
70            .and_then(|v| v.as_str())
71            .map(|s| s.to_uppercase()),
72    })
73}