Compare commits

...
12 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5 26c1762dfa A reply to a case mail becomes a note on its record
Test / test (push) Failing after 53s
Publish release / publish (push) Successful in 3m14s
Whatever reads the mailbox publishes on portal.mail.received; portal
appends the note after checking the record exists in a bucket this
site has and the sender is the address the record itself holds. A
note never moves a case: a mailbox is the one door anyone may knock
on, so it cannot drive a state machine even if someone forges their
way past the checks. The desks show the conversation beside the case.

Tested: the address parser, every refusal (a stranger, no address to
compare, an empty reply, the same reply twice, an unknown contract),
and the whole path through a real JetStream - the note lands, the
state does not move, and no event is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:26:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 4fd60293fa A field can be a place, and a question can be about one
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.

Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:17:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9ed8194484 Release 0.3.47
Test / test (push) Failing after 55s
Publish release / publish (push) Successful in 3m48s
iris learns stage_words; portal unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 2ac34b6167 Release 0.3.46
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 3m45s
An invite finds an existing account by its email and only adds it to
the group.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:18:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3c68814030 Release 0.3.45
Test / test (push) Successful in 1m3s
Publish release / publish (push) Successful in 3m18s
A mail stream that cannot be created at boot is a warning, not a
failed start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:24:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 0c68396d5b Release 0.3.44
Test / test (push) Failing after 1m3s
Publish release / publish (push) Successful in 3m6s
The mail stream is `mail`, not shouted; gdo 0.1.2 matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:15:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1ca3ac6807 Release 0.3.43
Test / test (push) Successful in 49s
Publish release / publish (push) Successful in 2m53s
Mail as content, delivered by uhhm/gdo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:45:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 19c8f541de Mail is content: a state's mail: and the invite mail, published for gdo
`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:45:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6d60f8b9a3 Release 0.3.42
Test / test (push) Successful in 52s
Publish release / publish (push) Successful in 2m57s
people: the Kanidm person lookup reads the body (200 + null is a
missing person in Kanidm 1.11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:33:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f36d88a69c people: a missing person is 200 + null in Kanidm 1.11, not 404
The existence check went by status alone, so every invitee looked like
an existing account: creation was skipped and the group step failed.
Read the body; `null` means nobody there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:33:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef46b463e4 CHANGELOG: kasse sites run v0.3.41
Test / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 12:57:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4818c5a26f CHANGELOG: the sections for 0.3.39 to 0.3.41; publish refuses a tag without one
Test / test (push) Failing after 28s
Three releases went out with no section because the edit that moves
Unreleased under a version heading was a no-op when no Unreleased
section existed, and nothing checked. Now the publish job fails when
the tag has no section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:54:28 +02:00
19 changed files with 1708 additions and 17 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -3265,7 +3265,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.41"
version = "0.4.0"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.41"
version = "0.4.0"
edition = "2021"
[lib]
+128
View File
@@ -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
+80
View File
@@ -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(),
+25
View File
@@ -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
View File
@@ -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 })
+147
View File
@@ -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 }");
+52
View File
@@ -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())
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(&not), None);
}
}
#[test]
#[cfg(feature = "ssr")]
fn a_style_id_is_two_plain_segments_or_the_default() {
std::env::set_var("MAPBOX_STYLE", "mapbox/outdoors-v12");
assert_eq!(style_id(), "mapbox/outdoors-v12");
for bad in ["../../secrets", "mapbox/streets-v12?x=1", "mapbox", "a/b/c"] {
std::env::set_var("MAPBOX_STYLE", bad);
assert_eq!(style_id(), "mapbox/streets-v12", "{bad}");
}
std::env::remove_var("MAPBOX_STYLE");
}
}
+64 -8
View File
@@ -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
View File
@@ -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);
}
+29
View File
@@ -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");
}