Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c1762dfa | ||
|
|
4fd60293fa | ||
|
|
9ed8194484 | ||
|
|
2ac34b6167 | ||
|
|
3c68814030 | ||
|
|
0c68396d5b | ||
|
|
1ca3ac6807 | ||
|
|
19c8f541de | ||
|
|
6d60f8b9a3 | ||
|
|
f36d88a69c | ||
|
|
ef46b463e4 | ||
|
|
4818c5a26f |
@@ -147,6 +147,7 @@ jobs:
|
||||
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
# The release notes are this version's section of CHANGELOG.md.
|
||||
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
|
||||
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $tag - write it and re-tag"; exit 1; }
|
||||
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
|
||||
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "$body" "$api/releases" | jq .id) \
|
||||
|
||||
+48
-3
@@ -4,6 +4,51 @@ 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
|
||||
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)
|
||||
|
||||
- The mail stream is named `mail` (was `WORMHOLE`); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
|
||||
|
||||
## 0.3.43 (2026-09-23)
|
||||
|
||||
- Mail is content: `mail: { to, subject, body }` on a state in aggregates.yaml sends when a record enters that state (`to: submitter` or one address; `{field}`, `{site}`, `{chain}`, `{email}` in the templates), and an invite mails its one-time link built in; `site.yaml` gains `mail: { from, reply_to, invite }`. Portal renders and publishes on `portal.mail.send` in the `WORMHOLE` stream; uhhm/gdo delivers. The lint requires a required `email` field on every form recording into a bucket that mails the submitter.
|
||||
|
||||
## 0.3.42 (2026-09-23)
|
||||
|
||||
- people: the Kanidm person lookup reads the body, since Kanidm 1.11 answers a missing person with 200 and `null` rather than 404; before this every invite skipped account creation and failed on the group step
|
||||
|
||||
## 0.3.41 (2026-09-23)
|
||||
|
||||
- publish: the iris release step retries on a fresh clone when its push loses a race against a push to iris main (the v0.3.40 tag never published)
|
||||
- iris: `check --access` prints the access matrix, and every check validates the compiled policy against the schema
|
||||
|
||||
## 0.3.40 (2026-09-23)
|
||||
|
||||
- One access policy, compiled from content into Cedar on every load (`src/access.rs`): pages, resource reads, desk transitions, self transitions and the automation endpoint all decide through it; decisions carry rule ids, refusals and mutations publish on `portal.access.decided`, and `portal.access.<site>.may` answers the same question for other apps over NATS
|
||||
- `invite: {group}` on an alternative onboards a person into Kanidm from a desk (`src/people.rs`, `KANIDM_API_TOKEN`), recording the one-time credential link in `portal_invites` and on `portal.people.invited`
|
||||
|
||||
## 0.3.39 (2026-09-23)
|
||||
|
||||
- publish: releases the matching uhhm/iris by proxy - re-pins, builds, tests and tags it as this version (needs the `IRIS_PUSH_USER` variable and `IRIS_PUSH_TOKEN` secret); iris carries portal's version number from here on
|
||||
|
||||
## 0.3.38 (2026-09-22)
|
||||
|
||||
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
|
||||
@@ -287,6 +332,6 @@ tarball deploy on kasse.
|
||||
|---|---|---|---|
|
||||
| uhhm.no | ergo | `app@uhhm-portal` | v0.3.36 |
|
||||
| redoal.com | ergo | `app@redoal-portal` | v0.3.36 |
|
||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.36 |
|
||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.36 |
|
||||
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.3.36 |
|
||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.41 |
|
||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.41 |
|
||||
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.3.41 |
|
||||
|
||||
Generated
+1
-1
@@ -3265,7 +3265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.3.41"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.3.41"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[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
@@ -36,6 +36,32 @@ pub struct AggregateSchema {
|
||||
/// rule has an explicit, auditable opt-out instead of a silent
|
||||
/// one, and so the next reader knows where the answers go.
|
||||
pub attended_by: Option<String>,
|
||||
/// The mail a record's arrival in a state sends (see `mail.rs`):
|
||||
/// declared on the state in aggregates.yaml, next to the event
|
||||
/// that enters it, so content decides when a person hears
|
||||
/// something and the mailer never has to know a site.
|
||||
pub mail_for_state: HashMap<String, MailSpec>,
|
||||
}
|
||||
|
||||
/// A mail to send when a record enters a state. `to` is `submitter`
|
||||
/// (the record's `email` field, which the lint then requires the
|
||||
/// forms to collect) or one fixed address. Subject and body are
|
||||
/// templates: `{field}` for any field of the record, plus `{site}`
|
||||
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
|
||||
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
|
||||
/// as plain text with an HTML alternative.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MailSpec {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl MailSpec {
|
||||
pub fn to_submitter(&self) -> bool {
|
||||
self.to == "submitter"
|
||||
}
|
||||
}
|
||||
|
||||
impl AggregateSchema {
|
||||
@@ -43,6 +69,10 @@ impl AggregateSchema {
|
||||
self.event_for_state.contains_key(state)
|
||||
}
|
||||
|
||||
pub fn mail_for(&self, state: &str) -> Option<&MailSpec> {
|
||||
self.mail_for_state.get(state)
|
||||
}
|
||||
|
||||
pub fn allowed(&self, state: &str) -> &[String] {
|
||||
self.transitions
|
||||
.get(state)
|
||||
@@ -54,6 +84,8 @@ impl AggregateSchema {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawState {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
mail: Option<MailSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -123,6 +155,22 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
}
|
||||
}
|
||||
|
||||
let mut mail_for_state = HashMap::new();
|
||||
for (state, raw_state) in raw_schema.states {
|
||||
if let Some(mail) = raw_state.mail {
|
||||
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
||||
}
|
||||
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
|
||||
mail.to
|
||||
);
|
||||
}
|
||||
mail_for_state.insert(state, mail);
|
||||
}
|
||||
}
|
||||
|
||||
out.insert(
|
||||
bucket.clone(),
|
||||
AggregateSchema {
|
||||
@@ -132,6 +180,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
state_for_event,
|
||||
transitions: raw_schema.transitions,
|
||||
attended_by: raw_schema.attended_by,
|
||||
mail_for_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -349,6 +398,37 @@ aggregates:
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_may_carry_the_mail_its_arrival_sends() {
|
||||
let schemas = parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: reports
|
||||
initial: open
|
||||
states:
|
||||
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
|
||||
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
|
||||
dropped: { event: dropped }
|
||||
transitions:
|
||||
open: [fixed, dropped]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = &schemas["reports"];
|
||||
assert!(s.mail_for("open").unwrap().to_submitter());
|
||||
assert_eq!(s.mail_for("fixed").unwrap().to, "board@example.no");
|
||||
assert!(s.mail_for("dropped").is_none());
|
||||
|
||||
for bad in [
|
||||
"mail: { to: submitter, subject: \"\", body: b }",
|
||||
"mail: { to: \"the board\", subject: s, body: b }",
|
||||
"mail: { to: submitter, subject: s, body: b, cc: x }",
|
||||
] {
|
||||
let yaml = format!("aggregates:\n - bucket: r\n initial: open\n states:\n open: {{ event: received, {bad} }}\n");
|
||||
assert!(parse_aggregates_yaml(&yaml).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
fn event(seq: u64, event_type: &str) -> Event {
|
||||
Event {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
|
||||
@@ -28,6 +28,26 @@ pub struct Answer {
|
||||
pub decided_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
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 -
|
||||
@@ -70,6 +90,7 @@ pub async fn store_answer(
|
||||
state: initial_state,
|
||||
submitted_ms,
|
||||
decided_ms: None,
|
||||
notes: Vec::new(),
|
||||
decided_by: None,
|
||||
};
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
@@ -268,6 +289,7 @@ async fn apply_transition(
|
||||
.put(&item.item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||
|
||||
// The decision extends the DAG as a child of the answer's own
|
||||
// submission hash, published like any other answer.
|
||||
@@ -378,6 +400,7 @@ pub async fn self_transition_answer(
|
||||
.put(&item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
||||
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
@@ -417,6 +440,7 @@ mod tests {
|
||||
submitted_ms: 1_700_000_000_000,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
@@ -427,6 +451,7 @@ mod tests {
|
||||
submitted_ms: 1_700_000_001_000,
|
||||
decided_ms: Some(1_700_000_002_000),
|
||||
decided_by: Some("bl".to_string()),
|
||||
notes: Vec::new(),
|
||||
},
|
||||
];
|
||||
let value = serde_json::Value::Array(
|
||||
|
||||
+200
-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,
|
||||
@@ -1087,6 +1158,7 @@ fn AlternativeCard(
|
||||
let question_id = question_id.clone();
|
||||
let alt_name = alt_name.clone();
|
||||
let feature_name = feature_name.clone();
|
||||
let _place = place.clone();
|
||||
move |req| {
|
||||
let field_id = format!("{}-{}", field_prefix, req.name);
|
||||
let label_for = field_id.clone();
|
||||
@@ -1245,6 +1317,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 +1754,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 +1803,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! {
|
||||
@@ -1835,6 +1976,7 @@ fn AnswerRow(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let submitted_ms = answer.submitted_ms;
|
||||
let notes = answer.notes.clone();
|
||||
let state = answer.state.clone();
|
||||
let item_id = answer.id.clone();
|
||||
let key = (feature_name.clone(), item_id.clone());
|
||||
@@ -1849,6 +1991,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">
|
||||
@@ -1864,6 +2027,29 @@ fn AnswerRow(
|
||||
<span class="answer-state">{state}</span>
|
||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||
</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={
|
||||
let available = available.clone();
|
||||
move || !available.is_empty()
|
||||
@@ -2027,7 +2213,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,
|
||||
@@ -2245,6 +2436,7 @@ pub async fn submit_answer(
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
|
||||
}
|
||||
crate::mail::on_invite(&state, &invited).await;
|
||||
}
|
||||
// `/shape/{curve.key}`: the next page may be chosen by the answer.
|
||||
let next = match alt.action.as_deref() {
|
||||
@@ -2298,6 +2490,11 @@ pub async fn submit_answer(
|
||||
{
|
||||
tracing::error!("failed to store answer in {bucket}: {e}");
|
||||
}
|
||||
// Arrival is a state too: what the schema says the first
|
||||
// state sends, it sends now.
|
||||
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
|
||||
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SubmitResult { next, chain_hash })
|
||||
|
||||
@@ -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,38 @@ 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`).
|
||||
#[serde(default)]
|
||||
pub mail: Option<MailConfig>,
|
||||
}
|
||||
|
||||
/// The sender identity, and the one built-in mail's wording.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MailConfig {
|
||||
/// `Name <address>` or a bare address, on a domain the host's
|
||||
/// mail server may send for.
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
/// Rewords the invite mail; the built-in text is in the site's
|
||||
/// `lang`. `{name}`, `{username}`, `{email}`, `{group}`, `{link}`,
|
||||
/// `{site}`, `{site_name}`, `{expires}`.
|
||||
#[serde(default)]
|
||||
pub invite: Option<MailTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MailTemplate {
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// What the landing page's hero is. `plain` is just the copy; `module`
|
||||
@@ -1012,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 }");
|
||||
|
||||
@@ -92,6 +92,29 @@ pub fn validate_questions(
|
||||
questions: &std::collections::HashMap<String, Question>,
|
||||
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
) -> anyhow::Result<()> {
|
||||
// A mail to the submitter needs an address to go to: every form
|
||||
// that records into that bucket has to collect a required email.
|
||||
for (bucket, schema) in aggregates {
|
||||
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
|
||||
if submitter_states.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let collects_email = alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == "email" && r.kind == "email" && !r.optional);
|
||||
if !collects_email {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email",
|
||||
question.id, alternative.name, submitter_states
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for question in questions.values() {
|
||||
if let Some(event) = &question.event {
|
||||
if question.is_dynamic() {
|
||||
@@ -101,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!(
|
||||
@@ -223,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())
|
||||
|
||||
@@ -22,6 +22,11 @@ pub mod access;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod people;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod mail;
|
||||
|
||||
pub mod maps;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
|
||||
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
//! Portal's side of the mail gate. Content decides when a person
|
||||
//! hears something (`mail:` on a state in aggregates.yaml, and the
|
||||
//! built-in invite mail); portal renders the message the moment a
|
||||
//! record enters that state and publishes it on one JetStream subject.
|
||||
//! gdo (uhhm/gdo) holds a durable consumer on that subject and hands
|
||||
//! each message to the host's SMTP. Portal never talks SMTP, gdo never
|
||||
//! reads content, and a message survives either of them being down.
|
||||
//!
|
||||
//! The message is the contract between the two, `Outgoing` below,
|
||||
//! versioned by `v`. Its id is the JetStream message id, so a
|
||||
//! transition that fires twice (a retry, a reseed) sends once.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The stream both sides create on boot, with the same config, so
|
||||
/// whichever starts first owns nothing the other lacks.
|
||||
pub const STREAM: &str = "mail";
|
||||
pub const SUBJECTS: &str = "portal.mail.>";
|
||||
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
|
||||
/// know rather than guessing.
|
||||
pub const CONTRACT: u8 = 1;
|
||||
|
||||
/// One mail, fully rendered: everything gdo needs and nothing it has
|
||||
/// to look up.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Outgoing {
|
||||
pub v: u8,
|
||||
/// `<bucket>:<record>:<state>` or `invite:<user>:<group>:<ms>` -
|
||||
/// the dedup key.
|
||||
pub id: String,
|
||||
pub site: String,
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub text: 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
|
||||
/// retention: a mail nobody delivered in a week is stale, not owed.
|
||||
/// The duplicate window has to fit inside it.
|
||||
pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
||||
Ok(js
|
||||
.get_or_create_stream(async_nats::jetstream::stream::Config {
|
||||
name: STREAM.to_string(),
|
||||
subjects: vec![SUBJECTS.to_string()],
|
||||
max_age: Duration::from_secs(7 * 24 * 3600),
|
||||
duplicate_window: Duration::from_secs(24 * 3600),
|
||||
..Default::default()
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `{key}` in a template becomes the variable's value, an unknown key
|
||||
/// becomes nothing, and a brace that opens no key stays a brace.
|
||||
pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find('{') {
|
||||
out.push_str(&rest[..start]);
|
||||
let after = &rest[start + 1..];
|
||||
let key_len = after
|
||||
.char_indices()
|
||||
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
if key_len > 0 && after[key_len..].starts_with('}') {
|
||||
let key = &after[..key_len];
|
||||
match vars.get(key) {
|
||||
Some(serde_json::Value::String(s)) => out.push_str(s),
|
||||
Some(serde_json::Value::Null) | None => {}
|
||||
Some(other) => out.push_str(&other.to_string()),
|
||||
}
|
||||
rest = &after[key_len + 1..];
|
||||
} else {
|
||||
out.push('{');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The HTML alternative: the markdown body as a single column with
|
||||
/// inline styles, which is as much as mail clients agree on. Raw HTML
|
||||
/// in the body is dropped, the same rule as page descriptions.
|
||||
pub fn to_html(markdown: &str) -> String {
|
||||
use pulldown_cmark::{html, Event, Options, Parser};
|
||||
let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
|
||||
let mut body = String::new();
|
||||
html::push_html(&mut body, parser);
|
||||
format!(
|
||||
"<!doctype html><html><body style=\"margin:0;padding:1.5em;background:#f4f4f2;color:#1c1c1a;font:16px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif\"><div style=\"max-width:36em;margin:0 auto;background:#fff;padding:1.5em 2em;border-radius:0.5em\">{body}</div></body></html>"
|
||||
)
|
||||
}
|
||||
|
||||
fn public_url() -> String {
|
||||
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn site_name(site: &crate::content::SiteConfig) -> String {
|
||||
site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string())
|
||||
}
|
||||
|
||||
/// The variables a state mail may use: every field of the record,
|
||||
/// then the runtime's own.
|
||||
pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut vars = serde_json::Map::new();
|
||||
if let Some(fields) = responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
vars.insert(k.clone(), match v {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||
serde_json::Value::Null => serde_json::Value::String(String::new()),
|
||||
other => serde_json::Value::String(other.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
vars.insert("email".into(), email.into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("chain".into(), id.into());
|
||||
vars.insert("bucket".into(), bucket.into());
|
||||
vars.insert("state".into(), state.into());
|
||||
vars
|
||||
}
|
||||
|
||||
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
|
||||
let mut headers = async_nats::HeaderMap::new();
|
||||
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
||||
let bytes = serde_json::to_vec(mail)?;
|
||||
js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A record entered `state` in `bucket`: send what the schema says,
|
||||
/// if anything. Best-effort by design - the transition is already
|
||||
/// committed, and a mail that cannot be rendered is logged, never a
|
||||
/// failed request.
|
||||
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
|
||||
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
||||
return;
|
||||
};
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
|
||||
return;
|
||||
};
|
||||
let vars = vars_for(bucket, id, to_state, responses);
|
||||
let to = if spec.to_submitter() {
|
||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
||||
Some(e) => e.to_string(),
|
||||
None => {
|
||||
tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spec.to.clone()
|
||||
};
|
||||
let text = render(&spec.body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("{bucket}:{id}:{to_state}"),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to,
|
||||
subject: render(&spec.subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(bucket, id, to_state, "publishing the mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The invite mail: the one-time link to the person just onboarded.
|
||||
/// Built in, since portal knows everything about it; `mail.invite` in
|
||||
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
||||
/// `{site}`, `{expires}`.
|
||||
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
|
||||
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||
(None, true, true) => (
|
||||
"Du er invitert inn i {group} på {site_name}".to_string(),
|
||||
"Hei {name},\n\nDu har fått en konto på {site} og en plass i {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}.".to_string(),
|
||||
),
|
||||
(None, false, true) => (
|
||||
"Du har fått plass i {group} på {site_name}".to_string(),
|
||||
"Hei {name},\n\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".to_string(),
|
||||
),
|
||||
(None, true, false) => (
|
||||
"You are invited into {group} at {site_name}".to_string(),
|
||||
"Hello {name},\n\nYou have an account at {site} and a place in {group}. Set your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}.".to_string(),
|
||||
),
|
||||
(None, false, false) => (
|
||||
"You now have a place in {group} at {site_name}".to_string(),
|
||||
"Hello {name},\n\nYour account at {site} now has a place in {group}. Sign in as usual.".to_string(),
|
||||
),
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let expires = invited
|
||||
.link_expires_ms
|
||||
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_default();
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("name".into(), invited.display_name.clone().into());
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("email".into(), invited.email.clone().into());
|
||||
vars.insert("group".into(), invited.group.clone().into());
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("invite:{}:{}:{now_ms}", invited.username, invited.group),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to: invited.email.clone(),
|
||||
subject: render(&subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(user = %invited.username, "publishing the invite mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn templates_take_fields_and_leave_stray_braces_alone() {
|
||||
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
|
||||
let vars = vars_for("reports", "abc", "fixed", &responses);
|
||||
assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no");
|
||||
assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {");
|
||||
assert_eq!(vars["bucket"], "reports");
|
||||
assert_eq!(vars["state"], "fixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
|
||||
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
|
||||
assert!(html.contains("<strong>there</strong>"));
|
||||
assert!(html.contains("<a href=\"https://x.no\">link</a>"));
|
||||
assert!(!html.contains("<script>"));
|
||||
assert!(html.starts_with("<!doctype html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_contract_round_trips() {
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: "reports:abc:fixed".into(),
|
||||
site: "Tomter Vel".into(),
|
||||
from: "Tomter Vel <vel@example.no>".into(),
|
||||
reply_to: None,
|
||||
to: "kari@x.no".into(),
|
||||
subject: "Fixed".into(),
|
||||
text: "Done.".into(),
|
||||
html: to_html("Done."),
|
||||
};
|
||||
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
+25
@@ -60,6 +60,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
// into, and `answers::store_answer` creates it on first use.
|
||||
let jetstream = async_nats::jetstream::new(nats.clone());
|
||||
portal::events::store::ensure_stream(&jetstream).await?;
|
||||
// Outgoing mail waits here for gdo; a mail is never lost to gdo
|
||||
// being down, and never sent by portal itself.
|
||||
// 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?);
|
||||
|
||||
@@ -100,6 +108,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
// windows (see announce.rs).
|
||||
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
|
||||
// COOKIE_SECURE=true (set it behind TLS in production) - same
|
||||
// defaults cnats uses. SameSite=Lax (tower-sessions defaults to
|
||||
@@ -143,6 +156,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()
|
||||
@@ -161,6 +178,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
|
||||
@@ -185,6 +208,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");
|
||||
}
|
||||
}
|
||||
+64
-8
@@ -31,6 +31,26 @@ pub struct Invited {
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Whether a `GET /v1/person/<name>` answer means the person exists.
|
||||
/// Kanidm 1.11 answers a missing person with 200 and the body `null`
|
||||
/// rather than 404, so the status alone would report everyone as
|
||||
/// existing and no account would ever get created. `None` for an
|
||||
/// answer that is neither.
|
||||
fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
|
||||
match status {
|
||||
200 => Some(body.trim() != "null" && !body.trim().is_empty()),
|
||||
404 => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
base: String,
|
||||
token: String,
|
||||
@@ -60,19 +80,38 @@ impl Kanidm {
|
||||
}
|
||||
|
||||
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, _) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
||||
match status {
|
||||
200 => Ok(true),
|
||||
404 => Ok(false),
|
||||
other => anyhow::bail!("kanidm: looking up {username}: http {other}"),
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
||||
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
|
||||
/// 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> {
|
||||
let username = username_for(email);
|
||||
let created = if self.person_exists(&username).await? {
|
||||
let existing = self.find_by_mail(email).await?;
|
||||
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||
let created = if existing.is_some() || self.person_exists(&username).await? {
|
||||
false
|
||||
} else {
|
||||
let (status, text) = self
|
||||
@@ -161,4 +200,21 @@ mod tests {
|
||||
assert_eq!(username_for("42@x.no"), "person");
|
||||
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]
|
||||
fn a_null_person_answer_means_nobody_there() {
|
||||
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
|
||||
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
|
||||
assert_eq!(person_lookup_found(404, ""), Some(false));
|
||||
assert_eq!(person_lookup_found(401, "unauthorized"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+107
-1
@@ -1120,4 +1120,110 @@ textarea:focus {
|
||||
flex-direction: column;
|
||||
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");
|
||||
assert_eq!(replayed.state, "won");
|
||||
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