Files
portal/src/maps.rs
T

247 lines
9.6 KiB
Rust
Raw Normal View History

//! 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 `<owner>/<style-id>`. 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<String> {
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": "© <a href=\"https://www.mapbox.com/about/maps/\">Mapbox</a> © <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>",
}
},
"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 `<lon>,<lat>,<zoom>` and `{size}` is `<w>x<h>`.
#[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 <lon>,<lat>,<zoom>").into_response();
};
let Some((w, h)) = parse_size(&size) else {
return (StatusCode::BAD_REQUEST, "expected <width>x<height>, 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(),
}
}
/// `<lon>,<lat>,<zoom>`, 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(&not), 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");
}
}