//! Maps, served entirely from this origin. //! //! A `type: location` field lets someone put a pin on a map, and a //! record that holds one shows where it is. Both need tiles, and tiles //! come from Mapbox - but the visitor's browser never talks to Mapbox: //! portal fetches every tile and every static image itself, with the //! token kept server-side. Nothing third-party loads in the page, so //! there is no third-party cookie, no consent banner, and no address //! book of who looked at which hazard. //! //! `MAPBOX_TOKEN` unset means the whole feature is off: the field //! degrades to two number inputs and a stored location renders as its //! coordinates, the same fail-closed shape uploads take without //! `GARAGE_*`. //! //! Only the proxy handlers are server-side; reading a stored location //! and addressing its still are needed in the browser too, where the //! desks and the public lists render. #[cfg(feature = "ssr")] use axum::extract::Path; #[cfg(feature = "ssr")] use axum::http::{header, StatusCode}; #[cfg(feature = "ssr")] use axum::response::{IntoResponse, Response}; /// Where the still of one location lives on this origin. The path is /// the same shape the handler parses back out. pub fn still_url(lat: f64, lon: f64, zoom: f64, width: u32, height: u32) -> String { format!("/map/still/{lon:.5},{lat:.5},{zoom:.2}/{width}x{height}") } /// A Mapbox style, as `/`. Overridable per instance /// (`MAPBOX_STYLE`); anything that is not two plain segments is /// ignored, since it goes into a URL path. #[cfg(feature = "ssr")] fn style_id() -> String { std::env::var("MAPBOX_STYLE") .ok() .filter(|s| { let mut parts = s.split('/'); let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else { return false; }; [owner, id].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')) }) .unwrap_or_else(|| "mapbox/streets-v12".to_string()) } /// The instance's Mapbox token, or `None` when maps are off. #[cfg(feature = "ssr")] pub fn token() -> Option { std::env::var("MAPBOX_TOKEN").ok().filter(|t| !t.trim().is_empty()) } #[cfg(feature = "ssr")] pub fn enabled() -> bool { token().is_some() } /// Everything MapLibre needs, pointed back at this origin: raster /// tiles, so there are no sprite or glyph URLs to rewrite and no /// second round of proxying. The labels are baked into the tiles. #[cfg(feature = "ssr")] pub async fn style() -> Response { if !enabled() { return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response(); } let style = serde_json::json!({ "version": 8, "sources": { "base": { "type": "raster", "tiles": ["/map/tiles/{z}/{x}/{y}"], "tileSize": 512, "attribution": "© Mapbox © OpenStreetMap", } }, "layers": [{ "id": "base", "type": "raster", "source": "base" }], }); ( [(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")], style.to_string(), ) .into_response() } /// One raster tile. The path is three integers and nothing else, so /// there is no way to steer the upstream request anywhere but a tile. #[cfg(feature = "ssr")] pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response { let Some(token) = token() else { return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response(); }; if z > 22 || x >= 1 << z.min(22) || y >= 1 << z.min(22) { return (StatusCode::BAD_REQUEST, "no such tile").into_response(); } let url = format!( "https://api.mapbox.com/styles/v1/{}/tiles/512/{z}/{x}/{y}?access_token={token}", style_id() ); fetch(&url, "image/png", "public, max-age=86400").await } /// A still map of one point, for a record that holds a location: an /// image, so a list of twenty of them costs no JavaScript at all. /// `{place}` is `,,` and `{size}` is `x`. #[cfg(feature = "ssr")] pub async fn still(Path((place, size)): Path<(String, String)>) -> Response { let Some(token) = token() else { return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response(); }; let Some((lon, lat, zoom)) = parse_place(&place) else { return (StatusCode::BAD_REQUEST, "expected ,,").into_response(); }; let Some((w, h)) = parse_size(&size) else { return (StatusCode::BAD_REQUEST, "expected x, at most 1280").into_response(); }; // The pin is drawn by Mapbox so the image is self-contained; the // marker colour is portal's ink, not a brand colour. let url = format!( "https://api.mapbox.com/styles/v1/{}/static/pin-s+3c3c3a({lon},{lat})/{lon},{lat},{zoom},0/{w}x{h}@2x?access_token={token}", style_id() ); fetch(&url, "image/png", "public, max-age=604800").await } #[cfg(feature = "ssr")] async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response { let resp = match crate::http::client().get(url).send().await.and_then(|r| r.error_for_status()) { Ok(r) => r, Err(e) => { tracing::warn!("map upstream: {e}"); return (StatusCode::BAD_GATEWAY, "the map service did not answer").into_response(); } }; let upstream = resp .headers() .get(openidconnect::reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .filter(|v| v.starts_with("image/")) .map(str::to_string); match crate::http::bytes(resp, crate::http::ASSET_CAP).await { Ok(bytes) => ( [ (header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())), (header::CACHE_CONTROL, cache.to_string()), ], axum::body::Bytes::from(bytes), ) .into_response(), Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(), } } /// `,,`, each a plain number in range. Formatted back /// out rather than passed through, so nothing of the caller's string /// reaches the upstream URL. #[cfg(feature = "ssr")] fn parse_place(place: &str) -> Option<(String, String, String)> { let mut parts = place.split(','); let (Some(lon), Some(lat), Some(zoom), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { return None; }; let lon: f64 = lon.parse().ok()?; let lat: f64 = lat.parse().ok()?; let zoom: f64 = zoom.parse().ok()?; if !(-180.0..=180.0).contains(&lon) || !(-85.0511..=85.0511).contains(&lat) || !(0.0..=22.0).contains(&zoom) { return None; } Some((format!("{lon:.5}"), format!("{lat:.5}"), format!("{zoom:.2}"))) } #[cfg(feature = "ssr")] fn parse_size(size: &str) -> Option<(u32, u32)> { let (w, h) = size.split_once('x')?; let (w, h): (u32, u32) = (w.parse().ok()?, h.parse().ok()?); (1..=1280).contains(&w).then_some(())?; (1..=1280).contains(&h).then_some(())?; Some((w, h)) } /// The `{lat, lon, zoom}` a `type: location` field stores, read back /// out of a record. Anything else is not a location. pub fn read(value: &serde_json::Value) -> Option<(f64, f64, f64)> { let obj = value.as_object()?; let num = |k: &str| obj.get(k).and_then(serde_json::Value::as_f64); let (lat, lon) = (num("lat")?, num("lon")?); if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) { return None; } Some((lat, lon, num("zoom").unwrap_or(13.0).clamp(0.0, 22.0))) } #[cfg(test)] mod tests { use super::*; #[test] #[cfg(feature = "ssr")] fn a_place_is_three_numbers_in_range_and_is_reformatted() { assert_eq!( parse_place("11.123456,59.987654,13.5"), Some(("11.12346".into(), "59.98765".into(), "13.50".into())) ); for bad in ["11,59", "11,59,13,0", "200,59,13", "11,95,13", "11,59,30", "11,59,x", "11,59,13?a=b"] { assert_eq!(parse_place(bad), None, "{bad}"); } } #[test] #[cfg(feature = "ssr")] fn a_size_is_two_numbers_within_what_the_service_draws() { assert_eq!(parse_size("600x300"), Some((600, 300))); for bad in ["600", "0x300", "600x0", "2000x300", "600x2000", "600x300x2", "sixx300"] { assert_eq!(parse_size(bad), None, "{bad}"); } } #[test] fn a_location_is_a_lat_and_a_lon_and_a_zoom_it_may_leave_out() { let v = serde_json::json!({ "lat": 59.5, "lon": 11.0, "zoom": 16 }); assert_eq!(read(&v), Some((59.5, 11.0, 16.0))); assert_eq!(read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })), Some((59.5, 11.0, 13.0))); for not in [ serde_json::json!({ "lat": 91.0, "lon": 11.0 }), serde_json::json!({ "lat": 59.5 }), serde_json::json!("59.5, 11.0"), serde_json::Value::Null, ] { assert_eq!(read(¬), None); } } #[test] #[cfg(feature = "ssr")] fn a_style_id_is_two_plain_segments_or_the_default() { std::env::set_var("MAPBOX_STYLE", "mapbox/outdoors-v12"); assert_eq!(style_id(), "mapbox/outdoors-v12"); for bad in ["../../secrets", "mapbox/streets-v12?x=1", "mapbox", "a/b/c"] { std::env::set_var("MAPBOX_STYLE", bad); assert_eq!(style_id(), "mapbox/streets-v12", "{bad}"); } std::env::remove_var("MAPBOX_STYLE"); } }