A field can be a place, and a question can be about one
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.
Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ed8194484
commit
4fd60293fa
+169
-3
@@ -366,10 +366,26 @@ fn QuestionView(
|
||||
// (vendored, ~180KB) script runs - loaded once per page, and only
|
||||
// on pages where some alternative actually declares a deck.
|
||||
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
|
||||
// MapLibre is vendored and large, so it loads only where a field
|
||||
// actually asks for a place, and only where this instance can
|
||||
// serve tiles at all.
|
||||
// The page's place, in the form a location field opens on.
|
||||
let place_value = question.place.as_ref().map(crate::content::Place::as_value);
|
||||
let needs_map = site.maps
|
||||
&& question.alternatives.iter().any(|a| {
|
||||
a.features.iter().any(|f| f.requirements.iter().any(|r| r.kind == "location"))
|
||||
});
|
||||
|
||||
|
||||
view! {
|
||||
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
|
||||
{needs_map
|
||||
.then(|| {
|
||||
view! {
|
||||
<leptos_meta::Stylesheet href="/maplibre-gl.css" />
|
||||
<leptos_meta::Script src="/maplibre-gl.js" />
|
||||
}
|
||||
})}
|
||||
<Hero
|
||||
title=question.name.clone()
|
||||
description=question.description.clone()
|
||||
@@ -377,6 +393,27 @@ fn QuestionView(
|
||||
site=site.clone()
|
||||
current_id=question_id.clone()
|
||||
/>
|
||||
// A page that is about somewhere says where, under the
|
||||
// question: a still from this origin, and the place in words
|
||||
// for anyone who cannot see it.
|
||||
{question
|
||||
.place
|
||||
.clone()
|
||||
.filter(|_| site.maps)
|
||||
.map(|place| {
|
||||
let name = place.name.clone().unwrap_or_else(|| format!("{}, {}", place.lat, place.lon));
|
||||
view! {
|
||||
<figure class="question-place">
|
||||
<img
|
||||
class="map-still"
|
||||
src=crate::maps::still_url(place.lat, place.lon, place.zoom, 640, 280)
|
||||
alt=format!("Map of {name}")
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>{name}</figcaption>
|
||||
</figure>
|
||||
}
|
||||
})}
|
||||
<div class="alternatives">
|
||||
<For
|
||||
each={
|
||||
@@ -407,6 +444,7 @@ fn QuestionView(
|
||||
alternative=alt
|
||||
parent_hash=parent_hash.clone()
|
||||
query_email=query_email.clone()
|
||||
place=place_value.clone().unwrap_or_default()
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -591,6 +629,30 @@ mod voice {
|
||||
}
|
||||
}
|
||||
|
||||
// The `type: location` map (`map.js`, crate root): the same
|
||||
// hidden-input bridge, and a handle whose `stop()` tears the map down
|
||||
// on SPA navigation - MapLibre keeps a WebGL context and its own
|
||||
// listeners, and a page that has moved on must not keep either.
|
||||
#[cfg(feature = "hydrate")]
|
||||
mod map {
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/map.js")]
|
||||
extern "C" {
|
||||
pub type MapWidget;
|
||||
|
||||
#[wasm_bindgen(js_name = mountMap)]
|
||||
pub fn mount_map(
|
||||
container: &web_sys::HtmlDivElement,
|
||||
hidden: &web_sys::HtmlInputElement,
|
||||
initial: &str,
|
||||
) -> MapWidget;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
pub fn stop(this: &MapWidget);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
mod gesture {
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -765,7 +827,16 @@ fn AlternativeCard(
|
||||
alternative: Alternative,
|
||||
parent_hash: Option<String>,
|
||||
query_email: Option<String>,
|
||||
/// The page's own place as `lat,lon,zoom`, when it has one: where
|
||||
/// a `type: location` field opens if the field does not say
|
||||
/// otherwise. A question about a street corner should not hand the
|
||||
/// visitor the globe. Empty means the page is about nowhere.
|
||||
#[prop(optional)]
|
||||
place: String,
|
||||
) -> impl IntoView {
|
||||
// Only the browser mounts a map, so the server build never reads it.
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = &place;
|
||||
// A link carrying both ?chain= and ?email= against an alternative
|
||||
// that declares a self_transition renders as a one-button
|
||||
// confirmation instead of the normal form - the visitor already
|
||||
@@ -870,7 +941,7 @@ fn AlternativeCard(
|
||||
select_multi.insert(req.name.clone(), req.multiple);
|
||||
} else {
|
||||
// Widget-owned fields hold JSON objects, not strings.
|
||||
if req.kind == "gesture" || req.kind == "voice" {
|
||||
if req.kind == "gesture" || req.kind == "voice" || req.kind == "location" {
|
||||
gesture_fields.insert(req.name.clone());
|
||||
}
|
||||
// A voice field's preset is the key it records at,
|
||||
@@ -1245,6 +1316,60 @@ fn AlternativeCard(
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if req.kind == "location" {
|
||||
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
// `value` opens the map somewhere useful
|
||||
// ("lat,lon,zoom"); without it the map
|
||||
// opens on the world.
|
||||
let initial = req
|
||||
.value
|
||||
.clone()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| place.clone());
|
||||
let widget: StoredValue<Option<map::MapWidget>, LocalStorage> =
|
||||
StoredValue::new_local(None);
|
||||
Effect::new(move |_| {
|
||||
let (Some(container), Some(hidden)) =
|
||||
(container_ref.get(), hidden_ref.get())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if widget.with_value(|w| w.is_some()) {
|
||||
return;
|
||||
}
|
||||
widget.set_value(Some(map::mount_map(
|
||||
&container, &hidden, &initial,
|
||||
)));
|
||||
});
|
||||
on_cleanup(move || {
|
||||
widget.update_value(|opt| {
|
||||
if let Some(w) = opt.take() {
|
||||
w.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return view! {
|
||||
<div class="field">
|
||||
{label_text}
|
||||
<input
|
||||
id=field_id
|
||||
type="hidden"
|
||||
node_ref=hidden_ref
|
||||
prop:value=move || sig.get()
|
||||
on:input=move |ev| sig.set(event_target_value(&ev))
|
||||
/>
|
||||
<div class="map-wrap" node_ref=container_ref></div>
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if req.kind == "voice" {
|
||||
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
||||
@@ -1628,9 +1753,17 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
||||
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
|
||||
|
||||
let known = ["name", "title", "description", "url", "html_url", "audio"];
|
||||
// A place renders as a still from this origin, the same as one
|
||||
// stored on a record: the first location field wins, since a card
|
||||
// shows one map.
|
||||
let place = obj
|
||||
.iter()
|
||||
.filter(|(k, _)| !known.contains(&k.as_str()))
|
||||
.find_map(|(_, v)| crate::maps::read(v))
|
||||
.map(|(lat, lon, zoom)| (crate::maps::still_url(lat, lon, zoom, 480, 240), format!("{lat}, {lon}")));
|
||||
let extra: Vec<(String, String)> = obj
|
||||
.iter()
|
||||
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null())
|
||||
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null() && crate::maps::read(v).is_none())
|
||||
.map(|(k, v)| {
|
||||
let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
|
||||
(k.clone(), text)
|
||||
@@ -1669,6 +1802,13 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
||||
{description
|
||||
.map(|d| view! { <p class="item-card-description" inner_html=render_inline_markdown(&d)></p> })}
|
||||
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
|
||||
{place
|
||||
.map(|(src, coords)| {
|
||||
view! {
|
||||
<img class="map-still" src=src alt=format!("Map of {coords}") loading="lazy" />
|
||||
<span class="map-coords">{coords}</span>
|
||||
}
|
||||
})}
|
||||
{(!extra.is_empty())
|
||||
.then(|| {
|
||||
view! {
|
||||
@@ -1849,6 +1989,27 @@ fn AnswerRow(
|
||||
}
|
||||
key=|(k, _)| k.clone()
|
||||
children=move |(key, value)| {
|
||||
// A stored location is a place, so it renders as
|
||||
// one: a still from this origin, with the
|
||||
// coordinates under it for anyone who wants to
|
||||
// put them into something else.
|
||||
if let Some((lat, lon, zoom)) = crate::maps::read(&value) {
|
||||
return view! {
|
||||
<div class="answer-field answer-field-map">
|
||||
<span class="answer-field-key">{key}</span>
|
||||
<span class="answer-field-value">
|
||||
<img
|
||||
class="map-still"
|
||||
src=crate::maps::still_url(lat, lon, zoom, 480, 240)
|
||||
alt=format!("Map of {lat}, {lon}")
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="map-coords">{format!("{lat}, {lon}")}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
|
||||
view! {
|
||||
<div class="answer-field">
|
||||
@@ -2027,7 +2188,12 @@ pub async fn get_question(
|
||||
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
||||
use crate::server::AppState;
|
||||
let state = expect_context::<AppState>();
|
||||
Ok(state.site.load().as_ref().clone())
|
||||
let mut site = state.site.load().as_ref().clone();
|
||||
// Not content: whether this instance can serve tiles at all is its
|
||||
// own configuration, and the browser has to know before it decides
|
||||
// whether to draw a map or ask for two numbers.
|
||||
site.maps = crate::maps::enabled();
|
||||
Ok(site)
|
||||
}
|
||||
|
||||
/// Every question the visitor may be offered from `current`, as (id,
|
||||
|
||||
@@ -84,6 +84,51 @@ pub struct Question {
|
||||
/// task lands in the `portal_events` desk (see `announce.rs`).
|
||||
#[serde(default)]
|
||||
pub event: Option<EventConfig>,
|
||||
/// Where this question is about, when it is about somewhere: a
|
||||
/// still map under the question, drawn from this origin. `event`
|
||||
/// makes a page an occasion with a time; this makes one a place,
|
||||
/// and a `type: location` field on the page opens its map here
|
||||
/// rather than on the whole world.
|
||||
#[serde(default)]
|
||||
pub place: Option<Place>,
|
||||
}
|
||||
|
||||
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
|
||||
/// to 22 (a doorstep); 15 shows a street and its neighbours. `name` is
|
||||
/// what to call it in words, for anyone who cannot see the map.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Place {
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
#[serde(default = "default_zoom")]
|
||||
pub zoom: f64,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
fn default_zoom() -> f64 {
|
||||
15.0
|
||||
}
|
||||
|
||||
impl Place {
|
||||
/// `lat,lon,zoom`, the form a `type: location` field takes as its
|
||||
/// opening view.
|
||||
pub fn as_value(&self) -> String {
|
||||
format!("{},{},{}", self.lat, self.lon, self.zoom)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) {
|
||||
return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon));
|
||||
}
|
||||
if !(0.0..=22.0).contains(&self.zoom) {
|
||||
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
|
||||
}
|
||||
if self.name.as_ref().is_some_and(|n| n.trim().is_empty()) {
|
||||
return Err("place.name is empty; leave it out or say where this is".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's announcement window. `starts` is RFC 3339 with an offset
|
||||
@@ -543,6 +588,12 @@ pub struct SiteConfig {
|
||||
pub favicon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hero: HeroConfig,
|
||||
/// Whether this instance can draw maps: set from `MAPBOX_TOKEN` on
|
||||
/// the way to the browser (see `get_site`), never from content, so
|
||||
/// a `type: location` field knows whether to offer a map or two
|
||||
/// number inputs.
|
||||
#[serde(skip)]
|
||||
pub maps: bool,
|
||||
/// Who this site's mail comes from. Absent means the site sends
|
||||
/// nothing: a state's `mail:` and the invite mail are logged and
|
||||
/// skipped, never half-sent (see `mail.rs`).
|
||||
@@ -1038,6 +1089,76 @@ alternatives:
|
||||
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
|
||||
}
|
||||
|
||||
/// A page with one `type: location` field, and whatever else the
|
||||
/// test needs bolted onto the requirement.
|
||||
fn question_with_location(extra_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
r#"
|
||||
id: /t
|
||||
name: Where is it?
|
||||
alternatives:
|
||||
- name: A
|
||||
features:
|
||||
- name: Where
|
||||
requirements:
|
||||
- name: place
|
||||
label: Where
|
||||
type: location
|
||||
optional: true
|
||||
{extra_yaml}
|
||||
"#
|
||||
))
|
||||
.unwrap();
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_field_puts_its_own_value_there() {
|
||||
let questions = question_with_location("");
|
||||
validate_questions(&questions, &schema_fixture()).unwrap();
|
||||
|
||||
// It cannot also be loaded from somewhere.
|
||||
let bound = question_with_location(
|
||||
" bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }",
|
||||
);
|
||||
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("the visitor puts the pin"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_fields_opening_view_is_a_point_on_earth() {
|
||||
let good = question_with_location(" value: \"59.8938,11.0332,15\"");
|
||||
validate_questions(&good, &schema_fixture()).unwrap();
|
||||
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
|
||||
validate_questions(&no_zoom, &schema_fixture()).unwrap();
|
||||
|
||||
for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] {
|
||||
let questions = question_with_location(&format!(" value: {bad}"));
|
||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_about_a_place_says_where_and_how_close() {
|
||||
let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) };
|
||||
place.validate().unwrap();
|
||||
assert_eq!(place.as_value(), "59.8938,11.0332,15");
|
||||
// The default is a street and its neighbours, not the globe.
|
||||
let bare: Place = serde_yaml::from_str("lat: 59.8938\nlon: 11.0332").unwrap();
|
||||
assert_eq!(bare.zoom, 15.0);
|
||||
assert!(bare.name.is_none());
|
||||
|
||||
for bad in [
|
||||
Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None },
|
||||
Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None },
|
||||
Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None },
|
||||
Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) },
|
||||
] {
|
||||
assert!(bad.validate().is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_from_state_is_rejected() {
|
||||
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
||||
|
||||
@@ -124,6 +124,11 @@ pub fn validate_questions(
|
||||
anyhow::bail!("question {:?}: event: {e}", question.id);
|
||||
}
|
||||
}
|
||||
if let Some(place) = &question.place {
|
||||
if let Err(e) = place.validate() {
|
||||
anyhow::bail!("question {:?}: place: {e}", question.id);
|
||||
}
|
||||
}
|
||||
if let Some(target) = &question.requires_chain {
|
||||
if !questions.contains_key(target) {
|
||||
anyhow::bail!(
|
||||
@@ -246,6 +251,30 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if requirement.kind == "location" {
|
||||
if requirement.resource.is_some() || requirement.bind.is_some() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - the visitor puts the pin, it can't also load one from a resource or bind",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
// `value` opens the map somewhere useful. It is
|
||||
// the content author's own coordinates, so a
|
||||
// typo here is a map of the Atlantic.
|
||||
if let Some(value) = &requirement.value {
|
||||
let parts: Vec<&str> = value.split(',').map(str::trim).collect();
|
||||
let ok = (2..=3).contains(&parts.len())
|
||||
&& parts.iter().all(|p| p.parse::<f64>().is_ok())
|
||||
&& parts[0].parse::<f64>().is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
|
||||
&& parts[1].parse::<f64>().is_ok_and(|lon| (-180.0..=180.0).contains(&lon));
|
||||
if !ok {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bind) = &requirement.bind {
|
||||
if bind.field == requirement.name
|
||||
|| !sibling_names.contains(bind.field.as_str())
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod people;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod mail;
|
||||
|
||||
pub mod maps;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
|
||||
|
||||
+12
@@ -151,6 +151,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
|
||||
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
|
||||
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
|
||||
// Vendored, not loaded from a CDN: a map must not be the one thing
|
||||
// on the page that phones a stranger.
|
||||
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
|
||||
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
|
||||
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
|
||||
|
||||
let app = Router::new()
|
||||
@@ -169,6 +173,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||
// Maps, proxied: the browser asks this origin for every tile
|
||||
// and every still, so nothing third-party loads in a page and
|
||||
// the token stays here (see maps.rs).
|
||||
.route("/map/style.json", get(portal::maps::style))
|
||||
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
|
||||
.route("/map/still/{place}/{size}", get(portal::maps::still))
|
||||
// no-cache = "revalidate before reuse", not "don't cache":
|
||||
// pkg files keep the same names across releases (portal.js,
|
||||
// portal.wasm), and without this browsers heuristically cache
|
||||
@@ -193,6 +203,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/swiper-element-bundle.min.js",
|
||||
ServeFile::new(swiper_path),
|
||||
)
|
||||
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
|
||||
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
|
||||
.leptos_routes_with_context(
|
||||
&state,
|
||||
routes,
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user