i3status_rs/
geolocator.rs1use backon::{ExponentialBuilder, Retryable as _};
55
56use crate::errors::{Error, ErrorContext as _, Result, StdError};
57use crate::wrappers::Seconds;
58use std::borrow::Cow;
59use std::fmt;
60use std::sync::{Arc, Mutex};
61use std::time::{Duration, Instant};
62
63use serde::Deserialize;
64use smart_default::SmartDefault;
65
66mod ip2location;
67mod ipapi;
68
69const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
74
75#[derive(Debug)]
76struct AutolocateResult {
77 location: IPAddressInfo,
78 timestamp: Instant,
79}
80
81#[derive(Debug, Clone, Copy)]
85pub struct RateLimited;
86
87impl fmt::Display for RateLimited {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 f.write_str("rate limited by the geolocation service")
90 }
91}
92
93impl StdError for RateLimited {}
94
95pub fn is_rate_limited(err: &Error) -> bool {
96 err.cause
97 .as_ref()
98 .is_some_and(|cause| cause.downcast_ref::<RateLimited>().is_some())
99}
100
101#[derive(Deserialize, Clone, Default, Debug)]
102pub struct IPAddressInfo {
103 pub ip: String,
105 pub latitude: f64,
106 pub longitude: f64,
107 pub city: String,
108
109 pub version: Option<String>,
111 pub region: Option<String>,
112 pub region_code: Option<String>,
113 pub country: Option<String>,
114 pub country_name: Option<String>,
115 pub country_code: Option<String>,
116 pub country_code_iso3: Option<String>,
117 pub country_capital: Option<String>,
118 pub country_tld: Option<String>,
119 pub continent_code: Option<String>,
120 pub in_eu: Option<bool>,
121 pub postal: Option<String>,
122 pub timezone: Option<String>,
123 pub utc_offset: Option<String>,
124 pub country_calling_code: Option<String>,
125 pub currency: Option<String>,
126 pub currency_name: Option<String>,
127 pub languages: Option<String>,
128 pub country_area: Option<f64>,
129 pub country_population: Option<f64>,
130 pub asn: Option<String>,
131 pub org: Option<String>,
132}
133
134#[derive(Debug, Deserialize)]
135#[serde(from = "GeolocatorConfig")]
136pub struct Geolocator {
137 backend: GeolocatorBackend,
138 rate_limit_interval: Duration,
139 last_autolocate: Mutex<Option<AutolocateResult>>,
140 last_rate_limited: Mutex<Option<Instant>>,
141}
142
143impl Default for Geolocator {
144 fn default() -> Self {
145 GeolocatorConfig::default().into()
146 }
147}
148
149impl Geolocator {
150 pub fn name(&self) -> Cow<'static, str> {
151 self.backend.name()
152 }
153
154 pub fn rate_limit_interval(&self) -> Duration {
155 self.rate_limit_interval
156 }
157
158 pub async fn find_ip_location(
166 &self,
167 client: &reqwest::Client,
168 interval: Duration,
169 ) -> Result<IPAddressInfo> {
170 {
171 let guard = self.last_autolocate.lock().unwrap();
172 if let Some(cached) = &*guard
173 && cached.timestamp.elapsed() < interval
174 {
175 return Ok(cached.location.clone());
176 }
177 }
178
179 {
180 let guard = self.last_rate_limited.lock().unwrap();
181 if let Some(at) = *guard
182 && at.elapsed() < self.rate_limit_interval
183 {
184 return Err(Error {
185 message: Some("geolocation service is rate limited, backing off".into()),
186 cause: Some(Arc::new(RateLimited)),
187 });
188 }
189 }
190
191 let fetch = || self.backend.get_info(client);
192 let location = match fetch
193 .retry(ExponentialBuilder::default())
194 .when(|err| !is_rate_limited(err))
195 .await
196 {
197 Ok(location) => location,
198 Err(err) => {
199 if is_rate_limited(&err) {
200 *self.last_rate_limited.lock().unwrap() = Some(Instant::now());
201 }
202 return Err(err);
203 }
204 };
205
206 {
207 let mut guard = self.last_autolocate.lock().unwrap();
208 *guard = Some(AutolocateResult {
209 location: location.clone(),
210 timestamp: Instant::now(),
211 });
212 }
213
214 Ok(location)
215 }
216}
217
218#[derive(Deserialize, Debug, SmartDefault)]
219#[serde(default)]
220pub struct GeolocatorConfig {
221 #[serde(flatten)]
222 backend: GeolocatorBackend,
223 #[default(600.into())]
226 rate_limit_interval: Seconds,
227}
228
229#[derive(Deserialize, Debug, SmartDefault, Clone)]
230#[serde(tag = "geolocator", rename_all = "lowercase", deny_unknown_fields)]
231pub enum GeolocatorBackend {
232 #[default]
233 Ipapi(ipapi::Config),
234 Ip2Location(ip2location::Config),
235}
236
237impl GeolocatorBackend {
238 fn name(&self) -> Cow<'static, str> {
239 match self {
240 GeolocatorBackend::Ipapi(_) => ipapi::Ipapi.name(),
241 GeolocatorBackend::Ip2Location(_) => ip2location::Ip2Location.name(),
242 }
243 }
244
245 async fn get_info(&self, client: &reqwest::Client) -> Result<IPAddressInfo> {
246 match self {
247 GeolocatorBackend::Ipapi(_) => ipapi::Ipapi.get_info(client).await,
248 GeolocatorBackend::Ip2Location(config) => {
249 ip2location::Ip2Location
250 .get_info(client, config.api_key.as_ref())
251 .await
252 }
253 }
254 }
255}
256
257impl From<GeolocatorConfig> for Geolocator {
258 fn from(config: GeolocatorConfig) -> Self {
259 Self {
260 backend: config.backend,
261 rate_limit_interval: config.rate_limit_interval.0,
262 last_autolocate: Mutex::new(None),
263 last_rate_limited: Mutex::new(None),
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn deserialize_config() {
274 let geolocator: Geolocator = toml::from_str("geolocator = \"ipapi\"").unwrap();
275 assert!(matches!(geolocator.backend, GeolocatorBackend::Ipapi(_)));
276 assert_eq!(geolocator.rate_limit_interval, Duration::from_secs(600));
277
278 assert!(toml::from_str::<Geolocator>("").is_err());
282
283 let geolocator: Geolocator =
284 toml::from_str("geolocator = \"ipapi\"\nrate_limit_interval = 120").unwrap();
285 assert_eq!(geolocator.rate_limit_interval, Duration::from_secs(120));
286
287 let geolocator: Geolocator =
288 toml::from_str("geolocator = \"ip2location\"\napi_key = \"xxx\"").unwrap();
289 let GeolocatorBackend::Ip2Location(config) = geolocator.backend else {
290 panic!("wrong backend");
291 };
292 assert_eq!(config.api_key.as_deref(), Some("xxx"));
293
294 assert!(toml::from_str::<Geolocator>("geolocator = \"ipapi\"\nbad_key = 1").is_err());
295 }
296}