Skip to main content

rama/cli/service/
geo.rs

1//! Shared bits for enriching CLI service responses with IP geolocation.
2
3use crate::http::{HeaderName, HeaderValue, Response};
4use crate::layer::MapOutputLayer;
5use crate::net::address::ip::geo::GeoLocation;
6
7/// The response header carrying the geolocation attribution (one value per
8/// notice). The notices themselves come from the loaded databases — see
9/// [`IpGeoDb::attributions`](crate::net::address::ip::geo::IpGeoDb::attributions).
10const GEO_ATTRIBUTION_HEADER: &str = "x-geo-attribution";
11
12/// A layer that appends each attribution `notice` to every response as an
13/// `x-geo-attribution` header value. Pass the loaded databases' notices
14/// (`IpGeoDb::attributions()`); add the layer only when that list is non-empty.
15pub fn geo_attribution_layer(
16    notices: Vec<&'static str>,
17) -> MapOutputLayer<impl Fn(Response) -> Response + Clone + Send + Sync + 'static> {
18    let name = HeaderName::from_static(GEO_ATTRIBUTION_HEADER);
19    MapOutputLayer::new(move |mut resp: Response| {
20        for notice in &notices {
21            resp.headers_mut()
22                .append(name.clone(), HeaderValue::from_static(notice));
23        }
24        resp
25    })
26}
27
28/// The attribution notices as a single well-formed HTML comment, or `None` when
29/// empty. Any `--` is neutralised so a notice cannot close the comment early
30/// (ours contain none, but the input is not assumed trusted).
31#[must_use]
32pub fn geo_attribution_html_comment(notices: &[&str]) -> Option<String> {
33    (!notices.is_empty()).then(|| {
34        let body = notices.join(" | ").replace("--", "- -");
35        format!("<!-- {body} -->")
36    })
37}
38
39/// Human-readable `(label, value)` rows for a resolved [`GeoLocation`], shared
40/// by the HTML renderers (the `serve ip` page panel and the `serve fp` report
41/// table). Empty fields are omitted.
42#[must_use]
43pub fn geo_location_rows(loc: &GeoLocation) -> Vec<(&'static str, String)> {
44    let mut rows = Vec::new();
45    if let Some(c) = &loc.country {
46        let name = c
47            .name()
48            .map(str::to_owned)
49            .unwrap_or_else(|| c.code().to_owned());
50        rows.push(("Country", format!("{name} ({})", c.code())));
51    }
52    if let Some(c) = &loc.continent {
53        rows.push((
54            "Continent",
55            c.name()
56                .map(str::to_owned)
57                .unwrap_or_else(|| c.code().to_owned()),
58        ));
59    }
60    if let Some(region) = loc.subdivisions.first().and_then(|s| s.name.as_deref()) {
61        rows.push(("Region", region.to_owned()));
62    }
63    if let Some(city) = &loc.city {
64        rows.push(("City", city.to_string()));
65    }
66    if let Some(postal) = &loc.postal_code {
67        rows.push(("Postal", postal.to_string()));
68    }
69    if let Some(l) = &loc.location {
70        rows.push((
71            "Coordinates",
72            format!("{:.4}, {:.4}", l.latitude, l.longitude),
73        ));
74        if let Some(tz) = &l.time_zone {
75            rows.push(("Time Zone", tz.to_string()));
76        }
77    }
78    if let Some(asys) = &loc.autonomous_system {
79        if let Some(asn) = asys.asn {
80            rows.push(("ASN", format!("AS{}", asn.as_u32())));
81        }
82        if let Some(org) = &asys.organization {
83            rows.push(("Network", org.to_string()));
84        }
85    }
86    rows
87}