247 lines
9.6 KiB
Rust
247 lines
9.6 KiB
Rust
//! 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(¬), 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");
|
||
|
|
}
|
||
|
|
}
|