Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c1762dfa | ||
|
|
4fd60293fa | ||
|
|
9ed8194484 | ||
|
|
2ac34b6167 | ||
|
|
3c68814030 |
@@ -4,6 +4,25 @@ One line per change, grouped by the release that shipped it. Newest
|
|||||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
content repos pin the tag they run (see the end of this file).
|
||||||
|
|
||||||
|
## 0.4.0 (2026-09-24)
|
||||||
|
|
||||||
|
- A field can be a place: `type: location` puts a pin on a map and stores `{lat, lon, zoom}`; a record holding one renders as a still map on its desk and in any resource card.
|
||||||
|
- A question can be about a place: `place: { lat, lon, zoom, name }` is the counterpart to `event:` - event makes a page an occasion with a time, place makes it somewhere, draws the map under the question, and opens any location field on that page there instead of on the globe.
|
||||||
|
- Maps are served from this origin. Portal fetches every tile and every still itself (`src/maps.rs`, MapLibre vendored), so a page with a map on it makes no third-party request, needs no consent banner, and the `MAPBOX_TOKEN` never leaves the server. Unset, the feature is simply off.
|
||||||
|
- A reply to a case mail becomes a note on its record: whatever reads the mailbox publishes on `portal.mail.received` and portal appends the note, after checking the record exists and the sender is the address the record itself holds. A note never moves a case.
|
||||||
|
|
||||||
|
## 0.3.47 (2026-09-24)
|
||||||
|
|
||||||
|
- iris: needs.yaml takes `stage_words`, each state in the site's own words, carried into the simulation model for the trainer's desk score. No change to portal itself.
|
||||||
|
|
||||||
|
## 0.3.46 (2026-09-23)
|
||||||
|
|
||||||
|
- An invite first looks the email up in Kanidm (`/v1/raw/search` on `mail`, when the service account may read people: `idm_people_pii_read`) and only adds an existing account, under whatever username, to the group; no second account, no reset link.
|
||||||
|
|
||||||
|
## 0.3.45 (2026-09-23)
|
||||||
|
|
||||||
|
- A mail stream that cannot be created at boot (subjects owned by an older stream) is a warning, not a failed start; 0.3.44 took the kasse sites down for two minutes on the rename.
|
||||||
|
|
||||||
## 0.3.44 (2026-09-23)
|
## 0.3.44 (2026-09-23)
|
||||||
|
|
||||||
- The mail stream is named `mail` (was `WORMHOLE`); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
|
- The mail stream is named `mail` (was `WORMHOLE`); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3265,7 +3265,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.44"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.44"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Glue for `type: location` requirement fields (see AlternativeCard in
|
||||||
|
// src/app.rs) - the same hidden-input bridge every widget kind uses,
|
||||||
|
// and the same typed handle with stop() that gesture.js and voice.js
|
||||||
|
// use so SPA navigation tears the map down.
|
||||||
|
//
|
||||||
|
// The pin is the centre of the map, not a draggable marker: panning
|
||||||
|
// works the same with a finger, a mouse or the arrow keys, and there
|
||||||
|
// is nothing to miss by a few pixels. Whatever the map is looking at
|
||||||
|
// when it stops moving is the answer, written into the paired hidden
|
||||||
|
// <input> as {"lat","lon","zoom"} with a real `input` event, so the
|
||||||
|
// Rust side reuses the signal wiring every other field already has.
|
||||||
|
//
|
||||||
|
// MapLibre is vendored and served from this origin (/maplibre-gl.js),
|
||||||
|
// and every tile comes from this origin too (/map/tiles/...), so a
|
||||||
|
// page with a map on it still talks to nobody but us.
|
||||||
|
|
||||||
|
const WAIT_MS = 15000;
|
||||||
|
|
||||||
|
async function maplibre() {
|
||||||
|
const started = Date.now();
|
||||||
|
while (!window.maplibregl) {
|
||||||
|
if (Date.now() - started > WAIT_MS) throw new Error('maplibre-gl did not load');
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
return window.maplibregl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The value as stored: five decimals is about a metre, which is as
|
||||||
|
// much as a pin on a map means.
|
||||||
|
function place(map) {
|
||||||
|
const c = map.getCenter();
|
||||||
|
return {
|
||||||
|
lat: Number(c.lat.toFixed(5)),
|
||||||
|
lon: Number(c.lng.toFixed(5)),
|
||||||
|
zoom: Number(map.getZoom().toFixed(2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where to open: what the field already holds, else what the content
|
||||||
|
// gave as `value` ("lat,lon,zoom"), else the whole world, which is
|
||||||
|
// honest about knowing nothing rather than guessing a country.
|
||||||
|
function opening(hidden, initial) {
|
||||||
|
try {
|
||||||
|
const held = JSON.parse(hidden.value || 'null');
|
||||||
|
if (held && typeof held.lat === 'number' && typeof held.lon === 'number') {
|
||||||
|
return { center: [held.lon, held.lat], zoom: held.zoom ?? 13, pinned: true };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const parts = String(initial || '').split(',').map((p) => Number(p.trim()));
|
||||||
|
if (parts.length >= 2 && parts.every((n) => Number.isFinite(n))) {
|
||||||
|
return { center: [parts[1], parts[0]], zoom: parts[2] ?? 13, pinned: false };
|
||||||
|
}
|
||||||
|
return { center: [0, 20], zoom: 1, pinned: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapWidget {
|
||||||
|
constructor(container, hidden, initial) {
|
||||||
|
this.container = container;
|
||||||
|
this.map = null;
|
||||||
|
this.dead = false;
|
||||||
|
this.start(container, hidden, initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(container, hidden, initial) {
|
||||||
|
let gl;
|
||||||
|
try {
|
||||||
|
gl = await maplibre();
|
||||||
|
} catch (e) {
|
||||||
|
container.textContent = 'The map could not be loaded. ' + (hidden.value ? 'Your saved place is kept.' : '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.dead) return;
|
||||||
|
|
||||||
|
const at = opening(hidden, initial);
|
||||||
|
const canvas = document.createElement('div');
|
||||||
|
canvas.className = 'map-canvas';
|
||||||
|
const pin = document.createElement('div');
|
||||||
|
pin.className = 'map-pin';
|
||||||
|
pin.setAttribute('aria-hidden', 'true');
|
||||||
|
const readout = document.createElement('p');
|
||||||
|
readout.className = 'map-readout';
|
||||||
|
container.append(canvas, pin, readout);
|
||||||
|
|
||||||
|
const map = new gl.Map({
|
||||||
|
container: canvas,
|
||||||
|
style: '/map/style.json',
|
||||||
|
center: at.center,
|
||||||
|
zoom: at.zoom,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
});
|
||||||
|
this.map = map;
|
||||||
|
map.addControl(new gl.NavigationControl({ showCompass: false }), 'top-right');
|
||||||
|
map.addControl(
|
||||||
|
new gl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false }),
|
||||||
|
'top-right',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing is answered until the map has been moved, or was opened
|
||||||
|
// on an answer it already had: an untouched map means "no place",
|
||||||
|
// not "the middle of the ocean".
|
||||||
|
let answered = at.pinned;
|
||||||
|
const write = () => {
|
||||||
|
if (!answered) return;
|
||||||
|
const value = place(map);
|
||||||
|
hidden.value = JSON.stringify(value);
|
||||||
|
hidden.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
readout.textContent = `${value.lat}, ${value.lon}`;
|
||||||
|
};
|
||||||
|
map.on('moveend', () => {
|
||||||
|
answered = true;
|
||||||
|
write();
|
||||||
|
});
|
||||||
|
map.on('load', write);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.dead = true;
|
||||||
|
if (this.map) {
|
||||||
|
this.map.remove();
|
||||||
|
this.map = null;
|
||||||
|
}
|
||||||
|
this.container.replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountMap(container, hidden, initial) {
|
||||||
|
return new MapWidget(container, hidden, initial);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,6 +28,26 @@ pub struct Answer {
|
|||||||
pub decided_ms: Option<i64>,
|
pub decided_ms: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub decided_by: Option<String>,
|
pub decided_by: Option<String>,
|
||||||
|
/// Replies that came back by mail, oldest first (see
|
||||||
|
/// `mail::note_for`). A note never moves a record; it is the
|
||||||
|
/// conversation beside the case, which is the thing a desk could
|
||||||
|
/// not see before.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub notes: Vec<Note>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reply on a record. It lives here rather than with the mail
|
||||||
|
/// code because it is part of what a record is: the desks render it,
|
||||||
|
/// and the browser has to know its shape even though it will never
|
||||||
|
/// speak to a mail server.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: String,
|
||||||
|
pub from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: String,
|
||||||
|
pub text: String,
|
||||||
|
pub received_ms: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a submitted answer into `bucket`, creating it on first use -
|
/// Writes a submitted answer into `bucket`, creating it on first use -
|
||||||
@@ -70,6 +90,7 @@ pub async fn store_answer(
|
|||||||
state: initial_state,
|
state: initial_state,
|
||||||
submitted_ms,
|
submitted_ms,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
|
notes: Vec::new(),
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
};
|
};
|
||||||
let data = serde_json::to_vec(&answer)?;
|
let data = serde_json::to_vec(&answer)?;
|
||||||
@@ -419,6 +440,7 @@ mod tests {
|
|||||||
submitted_ms: 1_700_000_000_000,
|
submitted_ms: 1_700_000_000_000,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
|
notes: Vec::new(),
|
||||||
},
|
},
|
||||||
Answer {
|
Answer {
|
||||||
id: "def456".to_string(),
|
id: "def456".to_string(),
|
||||||
@@ -429,6 +451,7 @@ mod tests {
|
|||||||
submitted_ms: 1_700_000_001_000,
|
submitted_ms: 1_700_000_001_000,
|
||||||
decided_ms: Some(1_700_000_002_000),
|
decided_ms: Some(1_700_000_002_000),
|
||||||
decided_by: Some("bl".to_string()),
|
decided_by: Some("bl".to_string()),
|
||||||
|
notes: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let value = serde_json::Value::Array(
|
let value = serde_json::Value::Array(
|
||||||
|
|||||||
+194
-3
@@ -366,10 +366,26 @@ fn QuestionView(
|
|||||||
// (vendored, ~180KB) script runs - loaded once per page, and only
|
// (vendored, ~180KB) script runs - loaded once per page, and only
|
||||||
// on pages where some alternative actually declares a deck.
|
// on pages where some alternative actually declares a deck.
|
||||||
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
|
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! {
|
view! {
|
||||||
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
|
{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
|
<Hero
|
||||||
title=question.name.clone()
|
title=question.name.clone()
|
||||||
description=question.description.clone()
|
description=question.description.clone()
|
||||||
@@ -377,6 +393,27 @@ fn QuestionView(
|
|||||||
site=site.clone()
|
site=site.clone()
|
||||||
current_id=question_id.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">
|
<div class="alternatives">
|
||||||
<For
|
<For
|
||||||
each={
|
each={
|
||||||
@@ -407,6 +444,7 @@ fn QuestionView(
|
|||||||
alternative=alt
|
alternative=alt
|
||||||
parent_hash=parent_hash.clone()
|
parent_hash=parent_hash.clone()
|
||||||
query_email=query_email.clone()
|
query_email=query_email.clone()
|
||||||
|
place=place_value.clone().unwrap_or_default()
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.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")]
|
#[cfg(feature = "hydrate")]
|
||||||
mod gesture {
|
mod gesture {
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
@@ -765,7 +827,16 @@ fn AlternativeCard(
|
|||||||
alternative: Alternative,
|
alternative: Alternative,
|
||||||
parent_hash: Option<String>,
|
parent_hash: Option<String>,
|
||||||
query_email: 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 {
|
) -> 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
|
// A link carrying both ?chain= and ?email= against an alternative
|
||||||
// that declares a self_transition renders as a one-button
|
// that declares a self_transition renders as a one-button
|
||||||
// confirmation instead of the normal form - the visitor already
|
// confirmation instead of the normal form - the visitor already
|
||||||
@@ -870,7 +941,7 @@ fn AlternativeCard(
|
|||||||
select_multi.insert(req.name.clone(), req.multiple);
|
select_multi.insert(req.name.clone(), req.multiple);
|
||||||
} else {
|
} else {
|
||||||
// Widget-owned fields hold JSON objects, not strings.
|
// 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());
|
gesture_fields.insert(req.name.clone());
|
||||||
}
|
}
|
||||||
// A voice field's preset is the key it records at,
|
// A voice field's preset is the key it records at,
|
||||||
@@ -1087,6 +1158,7 @@ fn AlternativeCard(
|
|||||||
let question_id = question_id.clone();
|
let question_id = question_id.clone();
|
||||||
let alt_name = alt_name.clone();
|
let alt_name = alt_name.clone();
|
||||||
let feature_name = feature_name.clone();
|
let feature_name = feature_name.clone();
|
||||||
|
let _place = place.clone();
|
||||||
move |req| {
|
move |req| {
|
||||||
let field_id = format!("{}-{}", field_prefix, req.name);
|
let field_id = format!("{}-{}", field_prefix, req.name);
|
||||||
let label_for = field_id.clone();
|
let label_for = field_id.clone();
|
||||||
@@ -1245,6 +1317,60 @@ fn AlternativeCard(
|
|||||||
.into_any();
|
.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" {
|
if req.kind == "voice" {
|
||||||
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||||
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
||||||
@@ -1628,9 +1754,17 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
|||||||
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
|
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
|
||||||
|
|
||||||
let known = ["name", "title", "description", "url", "html_url", "audio"];
|
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
|
let extra: Vec<(String, String)> = obj
|
||||||
.iter()
|
.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)| {
|
.map(|(k, v)| {
|
||||||
let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
|
let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
|
||||||
(k.clone(), text)
|
(k.clone(), text)
|
||||||
@@ -1669,6 +1803,13 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
|||||||
{description
|
{description
|
||||||
.map(|d| view! { <p class="item-card-description" inner_html=render_inline_markdown(&d)></p> })}
|
.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> })}
|
{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())
|
{(!extra.is_empty())
|
||||||
.then(|| {
|
.then(|| {
|
||||||
view! {
|
view! {
|
||||||
@@ -1835,6 +1976,7 @@ fn AnswerRow(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let submitted_ms = answer.submitted_ms;
|
let submitted_ms = answer.submitted_ms;
|
||||||
|
let notes = answer.notes.clone();
|
||||||
let state = answer.state.clone();
|
let state = answer.state.clone();
|
||||||
let item_id = answer.id.clone();
|
let item_id = answer.id.clone();
|
||||||
let key = (feature_name.clone(), item_id.clone());
|
let key = (feature_name.clone(), item_id.clone());
|
||||||
@@ -1849,6 +1991,27 @@ fn AnswerRow(
|
|||||||
}
|
}
|
||||||
key=|(k, _)| k.clone()
|
key=|(k, _)| k.clone()
|
||||||
children=move |(key, value)| {
|
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());
|
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
|
||||||
view! {
|
view! {
|
||||||
<div class="answer-field">
|
<div class="answer-field">
|
||||||
@@ -1864,6 +2027,29 @@ fn AnswerRow(
|
|||||||
<span class="answer-state">{state}</span>
|
<span class="answer-state">{state}</span>
|
||||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
// What the person has written back since, oldest first: the
|
||||||
|
// conversation beside the case, which a desk could not see
|
||||||
|
// before. A note moves nothing, so there is no button here.
|
||||||
|
{(!notes.is_empty())
|
||||||
|
.then(|| {
|
||||||
|
view! {
|
||||||
|
<ol class="answer-notes">
|
||||||
|
<For
|
||||||
|
each=move || notes.clone()
|
||||||
|
key=|note| note.id.clone()
|
||||||
|
children=move |note| {
|
||||||
|
view! {
|
||||||
|
<li class="answer-note">
|
||||||
|
<span class="answer-note-who">{note.from}</span>
|
||||||
|
<span class="answer-note-time">{format_ms(note.received_ms)}</span>
|
||||||
|
<p class="answer-note-text">{note.text}</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
})}
|
||||||
<Show when={
|
<Show when={
|
||||||
let available = available.clone();
|
let available = available.clone();
|
||||||
move || !available.is_empty()
|
move || !available.is_empty()
|
||||||
@@ -2027,7 +2213,12 @@ pub async fn get_question(
|
|||||||
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
let state = expect_context::<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,
|
/// 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`).
|
/// task lands in the `portal_events` desk (see `announce.rs`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub event: Option<EventConfig>,
|
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
|
/// A page's announcement window. `starts` is RFC 3339 with an offset
|
||||||
@@ -543,6 +588,12 @@ pub struct SiteConfig {
|
|||||||
pub favicon: Option<String>,
|
pub favicon: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hero: HeroConfig,
|
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
|
/// Who this site's mail comes from. Absent means the site sends
|
||||||
/// nothing: a state's `mail:` and the invite mail are logged and
|
/// nothing: a state's `mail:` and the invite mail are logged and
|
||||||
/// skipped, never half-sent (see `mail.rs`).
|
/// skipped, never half-sent (see `mail.rs`).
|
||||||
@@ -1038,6 +1089,76 @@ alternatives:
|
|||||||
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
|
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]
|
#[test]
|
||||||
fn undeclared_from_state_is_rejected() {
|
fn undeclared_from_state_is_rejected() {
|
||||||
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
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);
|
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 let Some(target) = &question.requires_chain {
|
||||||
if !questions.contains_key(target) {
|
if !questions.contains_key(target) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -246,6 +251,30 @@ pub fn validate_questions(
|
|||||||
question.id, alternative.name, feature.name, requirement.name
|
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 let Some(bind) = &requirement.bind {
|
||||||
if bind.field == requirement.name
|
if bind.field == requirement.name
|
||||||
|| !sibling_names.contains(bind.field.as_str())
|
|| !sibling_names.contains(bind.field.as_str())
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ pub mod people;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
|
|
||||||
|
pub mod maps;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
|
|||||||
+205
@@ -19,6 +19,10 @@ use std::time::Duration;
|
|||||||
pub const STREAM: &str = "mail";
|
pub const STREAM: &str = "mail";
|
||||||
pub const SUBJECTS: &str = "portal.mail.>";
|
pub const SUBJECTS: &str = "portal.mail.>";
|
||||||
pub const SUBJECT: &str = "portal.mail.send";
|
pub const SUBJECT: &str = "portal.mail.send";
|
||||||
|
/// Where a reply to a case mail arrives, published by whatever reads
|
||||||
|
/// the mailbox (uhhm/gdo over JMAP). Portal turns it into a note on
|
||||||
|
/// the record it names and nothing else: no state moves on a reply.
|
||||||
|
pub const RECEIVED_SUBJECT: &str = "portal.mail.received";
|
||||||
/// Bumped when `Outgoing` changes shape; gdo refuses what it does not
|
/// Bumped when `Outgoing` changes shape; gdo refuses what it does not
|
||||||
/// know rather than guessing.
|
/// know rather than guessing.
|
||||||
pub const CONTRACT: u8 = 1;
|
pub const CONTRACT: u8 = 1;
|
||||||
@@ -41,6 +45,123 @@ pub struct Outgoing {
|
|||||||
pub html: String,
|
pub html: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reply that came back, already matched to a record by whatever
|
||||||
|
/// read the mailbox. Portal trusts the match no further than it can
|
||||||
|
/// check it: the record must exist, the bucket must be one this site
|
||||||
|
/// has, and the sender must be the address the record itself holds.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Incoming {
|
||||||
|
pub v: u8,
|
||||||
|
/// The mail's own id, so the same reply read twice lands once.
|
||||||
|
pub id: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub record: String,
|
||||||
|
/// Who sent it, as the mail server saw it.
|
||||||
|
pub from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: String,
|
||||||
|
/// The reply itself, already stripped of quoted history and
|
||||||
|
/// signatures by the reader; portal stores what it is given.
|
||||||
|
pub text: String,
|
||||||
|
pub received_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a reply may add to a record, and what it may never do. Returns
|
||||||
|
/// the note to append, or why the reply is not one.
|
||||||
|
///
|
||||||
|
/// The address a reply comes back on is unguessable, so it is proof of
|
||||||
|
/// having been sent the mail - but a mailbox is a public door, and an
|
||||||
|
/// address can be forged. The sender must therefore also match the
|
||||||
|
/// address the record already holds, which is the one portal wrote to
|
||||||
|
/// in the first place.
|
||||||
|
pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<crate::answers::Note, String> {
|
||||||
|
if incoming.v != CONTRACT {
|
||||||
|
return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v));
|
||||||
|
}
|
||||||
|
let text = incoming.text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err("the reply has no text".to_string());
|
||||||
|
}
|
||||||
|
let held = answer
|
||||||
|
.responses
|
||||||
|
.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|e| e.trim().to_lowercase())
|
||||||
|
.filter(|e| !e.is_empty())
|
||||||
|
.ok_or_else(|| "the record holds no address to compare the sender with".to_string())?;
|
||||||
|
if address_of(&incoming.from) != held {
|
||||||
|
return Err(format!("{} is not the address this record was written to", incoming.from));
|
||||||
|
}
|
||||||
|
if answer.notes.iter().any(|n| n.id == incoming.id) {
|
||||||
|
return Err("this reply is already on the record".to_string());
|
||||||
|
}
|
||||||
|
Ok(crate::answers::Note {
|
||||||
|
id: incoming.id.clone(),
|
||||||
|
from: address_of(&incoming.from),
|
||||||
|
subject: incoming.subject.trim().to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
received_ms: incoming.received_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bare address out of `Name <a@b>` or `a@b`, folded for
|
||||||
|
/// comparison. Mail addresses are case-insensitive in the part that
|
||||||
|
/// matters here, and a display name is not identity.
|
||||||
|
pub fn address_of(from: &str) -> String {
|
||||||
|
let inner = match (from.rfind('<'), from.rfind('>')) {
|
||||||
|
(Some(open), Some(close)) if close > open => &from[open + 1..close],
|
||||||
|
_ => from,
|
||||||
|
};
|
||||||
|
inner.trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listens for replies and writes each one onto the record it names.
|
||||||
|
/// A reply is a note and never a decision: nothing here moves a case,
|
||||||
|
/// so a mailbox - the one door anyone may knock on - cannot drive a
|
||||||
|
/// state machine even if someone forges their way past the checks.
|
||||||
|
pub async fn receive(state: crate::server::AppState) {
|
||||||
|
use futures::StreamExt;
|
||||||
|
let mut sub = match state.nats.subscribe(RECEIVED_SUBJECT).await {
|
||||||
|
Ok(sub) => sub,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "cannot listen for replies");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail");
|
||||||
|
while let Some(msg) = sub.next().await {
|
||||||
|
let incoming: Incoming = match serde_json::from_slice(&msg.payload) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("unreadable reply on {RECEIVED_SUBJECT}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match note_onto(&state, &incoming).await {
|
||||||
|
Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"),
|
||||||
|
Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The record's own bucket is the only one a reply may touch, and the
|
||||||
|
/// note is appended under the same read-modify-write the desks use.
|
||||||
|
async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> {
|
||||||
|
if !state.aggregates.load().contains_key(&incoming.bucket) && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) {
|
||||||
|
anyhow::bail!("{} is not a bucket of this site", incoming.bucket);
|
||||||
|
}
|
||||||
|
let store = state.jetstream.get_key_value(&incoming.bucket).await?;
|
||||||
|
let bytes = store
|
||||||
|
.get(&incoming.record)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no such record"))?;
|
||||||
|
let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?;
|
||||||
|
let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
answer.notes.push(note);
|
||||||
|
store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotent, called by portal at boot and by gdo. A week of
|
/// Idempotent, called by portal at boot and by gdo. A week of
|
||||||
/// retention: a mail nobody delivered in a week is stale, not owed.
|
/// retention: a mail nobody delivered in a week is stale, not owed.
|
||||||
/// The duplicate window has to fit inside it.
|
/// The duplicate window has to fit inside it.
|
||||||
@@ -280,5 +401,89 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
|
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
|
||||||
assert_eq!(back, mail);
|
assert_eq!(back, mail);
|
||||||
|
// A gdo that predates reply addresses sends none, and reads
|
||||||
|
// one from a portal that does: the field is optional on the
|
||||||
|
// wire in both directions.
|
||||||
|
let older: Outgoing = serde_json::from_str(
|
||||||
|
r#"{"v":1,"id":"a","site":"s","from":"f@x.no","to":"t@x.no","subject":"s","text":"t","html":"h"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(older.subject, "s");
|
||||||
|
assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(email: &str) -> crate::answers::Answer {
|
||||||
|
crate::answers::Answer {
|
||||||
|
id: "abc".into(),
|
||||||
|
question_id: "/report".into(),
|
||||||
|
alternative: "Report a hazard".into(),
|
||||||
|
responses: serde_json::json!({ "email": email, "where": "The bend" }),
|
||||||
|
state: "open".into(),
|
||||||
|
submitted_ms: 1,
|
||||||
|
decided_ms: None,
|
||||||
|
decided_by: None,
|
||||||
|
notes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(from: &str, text: &str) -> Incoming {
|
||||||
|
Incoming {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: "mail-1".into(),
|
||||||
|
bucket: "hazards".into(),
|
||||||
|
record: "abc".into(),
|
||||||
|
from: from.into(),
|
||||||
|
subject: "Re: We have it".into(),
|
||||||
|
text: text.into(),
|
||||||
|
received_ms: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reply_from_the_address_on_the_record_becomes_a_note() {
|
||||||
|
let note = note_for(&reply("Kari <KARI@x.no>", " It is fixed now. "), &record("kari@x.no")).unwrap();
|
||||||
|
assert_eq!(note.from, "kari@x.no");
|
||||||
|
assert_eq!(note.text, "It is fixed now.");
|
||||||
|
assert_eq!(note.id, "mail-1");
|
||||||
|
assert_eq!(note.received_ms, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reply_from_anyone_else_is_refused() {
|
||||||
|
// The address a reply arrives on is unguessable, but a From is
|
||||||
|
// a claim, and a mailbox is a door anyone may knock on.
|
||||||
|
let err = note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err();
|
||||||
|
assert!(err.contains("not the address this record was written to"), "{err}");
|
||||||
|
|
||||||
|
// Nothing to compare against is also a refusal, not a pass.
|
||||||
|
let mut anonymous = record("kari@x.no");
|
||||||
|
anonymous.responses = serde_json::json!({ "where": "The bend" });
|
||||||
|
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_reply_and_a_repeated_one_add_nothing() {
|
||||||
|
assert!(note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")).unwrap_err().contains("no text"));
|
||||||
|
|
||||||
|
let mut already = record("kari@x.no");
|
||||||
|
already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap());
|
||||||
|
let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err();
|
||||||
|
assert!(err.contains("already on the record"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() {
|
||||||
|
let mut future = reply("kari@x.no", "hello");
|
||||||
|
future.v = CONTRACT + 1;
|
||||||
|
assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_address_is_read_out_of_whatever_the_mail_server_said() {
|
||||||
|
assert_eq!(address_of("Kari Nordmann <Kari@X.no>"), "kari@x.no");
|
||||||
|
assert_eq!(address_of(" kari@x.no "), "kari@x.no");
|
||||||
|
assert_eq!(address_of("<kari@x.no>"), "kari@x.no");
|
||||||
|
// A display name that looks like an address is not the address.
|
||||||
|
assert_eq!(address_of("\"admin@bank.no\" <kari@x.no>"), "kari@x.no");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -62,7 +62,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
portal::events::store::ensure_stream(&jetstream).await?;
|
portal::events::store::ensure_stream(&jetstream).await?;
|
||||||
// Outgoing mail waits here for gdo; a mail is never lost to gdo
|
// Outgoing mail waits here for gdo; a mail is never lost to gdo
|
||||||
// being down, and never sent by portal itself.
|
// being down, and never sent by portal itself.
|
||||||
portal::mail::ensure_stream(&jetstream).await?;
|
// Best-effort: a stream that already owns these subjects under an
|
||||||
|
// older name (a rename mid-upgrade) must not keep a site from
|
||||||
|
// booting; mail waits until the old stream is removed.
|
||||||
|
if let Err(e) = portal::mail::ensure_stream(&jetstream).await {
|
||||||
|
tracing::warn!("the mail stream could not be created: {e} - outgoing mail is off until a stream owns portal.mail.>");
|
||||||
|
}
|
||||||
|
|
||||||
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
|
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
|
||||||
|
|
||||||
@@ -103,6 +108,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// windows (see announce.rs).
|
// windows (see announce.rs).
|
||||||
tokio::spawn(portal::announce::run(state.clone()));
|
tokio::spawn(portal::announce::run(state.clone()));
|
||||||
|
|
||||||
|
// Replies to case mail, matched to their record by whatever reads
|
||||||
|
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
|
||||||
|
// site with no mailbox simply never sees a message on the subject.
|
||||||
|
tokio::spawn(portal::mail::receive(state.clone()));
|
||||||
|
|
||||||
// Dev-friendly defaults: in-memory sessions, secure cookies only when
|
// Dev-friendly defaults: in-memory sessions, secure cookies only when
|
||||||
// COOKIE_SECURE=true (set it behind TLS in production) - same
|
// COOKIE_SECURE=true (set it behind TLS in production) - same
|
||||||
// defaults cnats uses. SameSite=Lax (tower-sessions defaults to
|
// defaults cnats uses. SameSite=Lax (tower-sessions defaults to
|
||||||
@@ -146,6 +156,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
|
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
|
||||||
let wordmark_path = format!("{}/wordmark.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);
|
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 fonts_dir = format!("{}/fonts", leptos_options.site_root);
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -164,6 +178,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Content-shipped assets (hero module etc.), same-origin.
|
// Content-shipped assets (hero module etc.), same-origin.
|
||||||
.route("/site/{*path}", get(content::site_asset_handler))
|
.route("/site/{*path}", get(content::site_asset_handler))
|
||||||
.route("/automation/kv/{bucket}", get(content::automation_kv_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":
|
// no-cache = "revalidate before reuse", not "don't cache":
|
||||||
// pkg files keep the same names across releases (portal.js,
|
// pkg files keep the same names across releases (portal.js,
|
||||||
// portal.wasm), and without this browsers heuristically cache
|
// portal.wasm), and without this browsers heuristically cache
|
||||||
@@ -188,6 +208,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"/swiper-element-bundle.min.js",
|
"/swiper-element-bundle.min.js",
|
||||||
ServeFile::new(swiper_path),
|
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(
|
.leptos_routes_with_context(
|
||||||
&state,
|
&state,
|
||||||
routes,
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-3
@@ -44,6 +44,13 @@ fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first entry's `name` in a `/v1/raw/search` answer, which is
|
||||||
|
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
|
||||||
|
fn name_from_search(body: &str) -> Option<String> {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||||
|
v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Kanidm {
|
pub struct Kanidm {
|
||||||
base: String,
|
base: String,
|
||||||
token: String,
|
token: String,
|
||||||
@@ -77,11 +84,34 @@ impl Kanidm {
|
|||||||
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account that already carries this email, if the token may
|
||||||
|
/// see it (the service account in `idm_people_pii_read`). None
|
||||||
|
/// when nobody has it, or when the search is not permitted: then
|
||||||
|
/// the username derived from the email decides, as before.
|
||||||
|
async fn find_by_mail(&self, email: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let (status, text) = self
|
||||||
|
.call(
|
||||||
|
openidconnect::reqwest::Method::POST,
|
||||||
|
"/v1/raw/search",
|
||||||
|
Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(name_from_search(&text))
|
||||||
|
}
|
||||||
|
|
||||||
/// The whole onboarding, in the order that leaves the least behind
|
/// The whole onboarding, in the order that leaves the least behind
|
||||||
/// if a step fails: create, then email, then group, then the link.
|
/// if a step fails: find or create, then email, then group, then
|
||||||
|
/// the link. A person who already has an account, under whatever
|
||||||
|
/// username, is only added to the group; their credentials are
|
||||||
|
/// left alone and no link is made.
|
||||||
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
|
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
|
||||||
let username = username_for(email);
|
let existing = self.find_by_mail(email).await?;
|
||||||
let created = if self.person_exists(&username).await? {
|
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||||
|
let created = if existing.is_some() || self.person_exists(&username).await? {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
let (status, text) = self
|
let (status, text) = self
|
||||||
@@ -171,6 +201,14 @@ mod tests {
|
|||||||
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_search_by_mail_yields_the_accounts_own_name() {
|
||||||
|
assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into()));
|
||||||
|
assert_eq!(name_from_search(r#"{"entries":[]}"#), None);
|
||||||
|
assert_eq!(name_from_search("null"), None);
|
||||||
|
assert_eq!(name_from_search("not json"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_null_person_answer_means_nobody_there() {
|
fn a_null_person_answer_means_nobody_there() {
|
||||||
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||||
|
|||||||
+107
-1
@@ -1120,4 +1120,110 @@ textarea:focus {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* A `type: location` field: the map fills the field, and the pin is the
|
||||||
|
centre of it - panning works the same with a finger, a mouse or the
|
||||||
|
arrow keys, and there is nothing to miss by a few pixels. MapLibre
|
||||||
|
draws into .map-canvas after hydration, so the box is reserved up
|
||||||
|
front the way .gesture-wrap reserves its own. */
|
||||||
|
.map-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrap:empty {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
border: 0.06rem solid var(--line);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The pin sits over the exact centre of the canvas and takes no
|
||||||
|
pointer events, so every drag goes to the map under it. Its point is
|
||||||
|
the bottom tip, hence the upward shift of its own height. */
|
||||||
|
.map-pin {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
margin: -1.35rem 0 0 -0.45rem;
|
||||||
|
border: 0.18rem solid var(--ink);
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-readout {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A stored location in a list: a still image from this origin, never a
|
||||||
|
live map - twenty rows of WebGL would cost more than the page. */
|
||||||
|
.map-still {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
height: auto;
|
||||||
|
border: 0.06rem solid var(--line);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-coords {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-field-map .answer-field-value {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Replies that came back by mail, under the record they belong to.
|
||||||
|
They move nothing, so there is no control here - only who wrote,
|
||||||
|
when, and what they said. */
|
||||||
|
.answer-notes {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
padding: 0 0 0 0.7rem;
|
||||||
|
border-left: 0.12rem solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-note-who {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-note-time {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-note-text {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A page that is about a place: the map sits under the question, at
|
||||||
|
the width of the text it belongs to. */
|
||||||
|
.question-place {
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-place figcaption {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,4 +63,33 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
|||||||
let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays");
|
let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays");
|
||||||
assert_eq!(replayed.state, "won");
|
assert_eq!(replayed.state, "won");
|
||||||
assert!(replayed.sequence > moved.sequence);
|
assert!(replayed.sequence > moved.sequence);
|
||||||
|
|
||||||
|
// A reply to the mail this record was sent becomes a note on it,
|
||||||
|
// through the same store the desks read - and moves nothing: the
|
||||||
|
// record is still where the desk left it.
|
||||||
|
let mut record: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||||
|
let reply = portal::mail::Incoming {
|
||||||
|
v: portal::mail::CONTRACT,
|
||||||
|
id: "mail-1".into(),
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
record: "rec-1".into(),
|
||||||
|
from: "A <A@B.example>".into(),
|
||||||
|
subject: "Re: your enquiry".into(),
|
||||||
|
text: "Thursday suits us.".into(),
|
||||||
|
received_ms: now + 10,
|
||||||
|
};
|
||||||
|
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
|
||||||
|
record.notes.push(note);
|
||||||
|
store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored");
|
||||||
|
|
||||||
|
let read_back: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||||
|
assert_eq!(read_back.notes.len(), 1);
|
||||||
|
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
|
||||||
|
assert_eq!(read_back.notes[0].from, "a@b.example");
|
||||||
|
assert_eq!(read_back.state, "quoted", "a reply is a note, never a decision");
|
||||||
|
// The same reply read twice adds nothing.
|
||||||
|
assert!(portal::mail::note_for(&reply, &read_back).is_err());
|
||||||
|
// And the event log is untouched by it.
|
||||||
|
let after = load_events(&js, &bucket, "rec-1").await.expect("events");
|
||||||
|
assert_eq!(after.len(), 3, "a note writes no event");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user