Compare commits
@@ -0,0 +1,45 @@
|
||||
name: docs
|
||||
|
||||
# docs/ as a site at portal.uhhm.no: built with mdBook and put to
|
||||
# git-pages on every push to main that touches it (the uhhm pages
|
||||
# pattern, uhhm/infrastructure PAGES.md). The changelog rides along as
|
||||
# the book's last page. Authorized by the _git-pages-forge-allowlist
|
||||
# TXT record for portal.uhhm.no plus a token the forge confirms may
|
||||
# push to this repo (the GITEA_TOKEN secret).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["docs/**", "book.toml", "CHANGELOG.md", ".gitea/workflows/docs.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MDBOOK_VERSION: v0.5.4
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: bare
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: build the book
|
||||
run: |
|
||||
set -eu
|
||||
tool=/var/local/cargo-target/mdbook-$MDBOOK_VERSION
|
||||
if [ ! -x "$tool/mdbook" ]; then
|
||||
mkdir -p "$tool"
|
||||
curl -sSfL "https://github.com/rust-lang/mdBook/releases/download/$MDBOOK_VERSION/mdbook-$MDBOOK_VERSION-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$tool"
|
||||
fi
|
||||
cp CHANGELOG.md docs/changelog.md
|
||||
"$tool/mdbook" build
|
||||
test -f target/book/index.html
|
||||
test -f target/book/memberships.html
|
||||
|
||||
- name: deploy to portal.uhhm.no
|
||||
run: |
|
||||
tar -czf site.tar.gz -C target/book .
|
||||
curl --fail -sS -X PUT "https://portal.uhhm.no/" \
|
||||
-H "Content-Type: application/x-tar+gzip" \
|
||||
-H "Forge-Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
--data-binary @site.tar.gz \
|
||||
-w "deployed: %{http_code}\n"
|
||||
@@ -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) \
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
.claude/
|
||||
docs/changelog.md
|
||||
|
||||
+124
-9
@@ -4,6 +4,121 @@ 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.5.11 (2026-09-30)
|
||||
|
||||
- Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else.
|
||||
|
||||
## 0.5.10 (2026-09-30)
|
||||
|
||||
- The lint lets an invite form collect a required `phone` in place of a required `email`: memberships know a person by number first.
|
||||
|
||||
## 0.5.9 (2026-09-30)
|
||||
|
||||
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
|
||||
|
||||
## 0.5.8 (2026-09-30)
|
||||
|
||||
- Fixed: 0.5.7 asked every provider for the `phoneNumber` scope, and Kanidm denies a sign-in that asks for a scope the person does not hold, so nobody could sign in. The scope is asked for only with `OIDC_EXTRA_SCOPES=phoneNumber` (Vipps). 0.5.7 must not be deployed on a Kanidm site.
|
||||
|
||||
## 0.5.7 (2026-09-30)
|
||||
|
||||
- Memberships: groups kept by portal itself (`src/memberships.rs`, `docs/memberships.md`). A membership is a case in the built-in `portal_memberships` bucket - `invited` by a desk, a `grants`, the seed or the responsible pass; `active` from the person's first sign-in, when the provider's subject, phone number or address matches; `removed` by a desk. A person is their phone number first (`+4791234567`, `PHONE_COUNTRY` for bare numbers), their address second, the provider's subject third, and one record per group and person however it was keyed. At sign-in the active memberships join the `groups` claim, so Cedar sees the same parents as before. `PEOPLE_BACKEND=memberships` switches a site; unset, a `KANIDM_API_TOKEN` means Kanidm and no token means memberships. `SEED_ADMIN_PHONE` seeds by number. The sign-in asks for the `phoneNumber` scope and reads `phone_number` from userinfo.
|
||||
- The NATS access service answers for a person named by `sub`, `phone` or `email` with no groups listed: their memberships are looked up first.
|
||||
- `who_for` (the person a `grants` is about) takes `navn` as well as `name`, and a phone number in place of an address.
|
||||
- The invite mail for someone who needs no link says where to sign in.
|
||||
|
||||
## 0.5.6 (2026-09-30)
|
||||
|
||||
- A group may hold a page's responsibility: `responsible: { name, contact, group: styret }`. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board's shared address was onboarded as a person.
|
||||
|
||||
## 0.5.5 (2026-09-30)
|
||||
|
||||
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
|
||||
- The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
|
||||
|
||||
## 0.5.4 (2026-09-29)
|
||||
|
||||
- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (`portal.content.reloaded`, published once new content is in place), so a new page's person is set up without a restart - and someone the content has stopped naming leaves `RESPONSIBLE_GROUP` in the same pass, if portal is who put them there.
|
||||
- An invite is a case that keeps itself current (`portal_invites`: `open`, `expired`, `done`). Signing in closes a person's invites; a sweep every ten minutes moves one whose link ran out unused to `expired`, and takes the link off the record. From `expired` a desk may send it again: the move back to `open` makes a fresh link and mails it. Content offers that with `{ from: expired, to: open }` on the list of invites. The invites portal makes itself at start are on file with the rest.
|
||||
- Fixed: an invite mail said the link worked "until" and then nothing. Kanidm 1.11 gives a link's end as seconds since the epoch, and portal read only the older RFC 3339 string.
|
||||
- Fixed: a search by mail that Kanidm failed to answer (a 5xx) was read as "not permitted", and the invite went on to match by username - which could give someone with an account under another name a second one. Only a refusal is read that way now.
|
||||
- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's `responsible.contact`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. It is a case in the built-in `portal_edit_suggestions` bucket - `open`, `discussed`, `done` or `declined` - so a desk that lists the bucket takes it up and settles it, the person who made it is mailed at each move, and the card shows them what became of what they sent before. The session now carries the signed-in person's email.
|
||||
- The mail to someone named responsible names the first three questions and counts the rest; on a site where one address answers for every page it used to list them all.
|
||||
- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and `idm_people_on_boarding` may create but not modify: 403. The address now goes in with the person.
|
||||
|
||||
## 0.5.3 (2026-09-29)
|
||||
|
||||
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
|
||||
|
||||
## 0.5.2 (2026-09-28)
|
||||
|
||||
- The rate limiter answered 500 to any request that arrived without `X-Forwarded-For`. It reads the caller from that header or `X-Real-IP` and falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed.
|
||||
|
||||
## 0.5.1 (2026-09-28)
|
||||
|
||||
- A `self_transition` that names no `from` means the bucket's own initial state, not the word `open`. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive at `pending` and confirm themselves into `open`, so the rule compiled as `open -> open` and the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists.
|
||||
|
||||
## 0.5.0 (2026-09-28)
|
||||
|
||||
- Sessions live in NATS KV (`src/sessions.rs`), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket's `max_age` collects what nobody returns for, and a record's own expiry is what answers a load.
|
||||
- Fixed: the JetStream integration test asserted a record's projected state was one a desk move had never written to it, so it failed whenever it actually ran. `test.yml` has been red since 0.3.47 while `publish.yml` stayed green, which is how 0.4.0 shipped past it.
|
||||
- A file can come back out. `GET /attachment/{bucket}/{record}/{field}` streams what a `type: file` field stored, reading the object key out of the record rather than taking one from the caller - so a file is only reachable through a record somebody may see, and a key that leaks off a desk is a link to nothing. Before this, uploads were one-way: a desk showed the key as a string and there was no way to open it.
|
||||
- Rate limiting, per caller IP, outside everything else: a burst of 30 refilled one every two seconds (`PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS`; either 0 turns it off). A form-filling visitor never reaches it; a script does. There was none at all before.
|
||||
- `/upload` asks the policy, like every other way in. It asked nothing: any caller who could reach the host could put 20 MB in the bucket, as often as they liked, and a site whose forms are public is a site whose upload route is public. It now takes the `question` and `alternative` the file is being attached to, refuses a page that declares no `type: file` field or an alternative that is closed, and asks `Submit` on that page - the same question the submission itself will ask - before a byte is written. Latent until an instance had storage configured; the first one now does.
|
||||
- A submitter stays in dialogue with their case for as long as it runs. `self_transition` takes `from: [state, ...]`, and the access policy compiles one rule per state named; it defaulted to the initial state alone and was written that way in the compiler, so an applicant written to for a missing drawing could not send it, and nobody could withdraw once anything had happened. Omitted, it still means the initial state alone.
|
||||
- A record can belong to another record. `type: record` with `of: <bucket>` stores the parent's id, which arrives on the link rather than being typed; a submission naming a record that is not there is refused. The other half is `where: { field, equals }` on a Kv resource, so an application's page can list the objections that name it.
|
||||
- A public view need not publish the whole bucket. `states: [...]` on a Kv resource shows only those, for one record as well as a listing - a register that is public by law is public about what was decided, not about what was withdrawn. The desk over the same bucket is a different spec and still sees all of it.
|
||||
- A state can grant a group: `grants: <group>` beside its `event`, so an approval that means membership does not also need somebody to remember to invite them afterwards. The lint requires the forms that fill such a bucket to collect a required name and email, because by the time a desk presses approve there is nobody left to ask.
|
||||
- A state can be moved by a clock: `after: { days, to }`, swept hourly, measured from the record's last move (`src/deadlines.rs`). A deadline may only do what a person could have done - `after.to` is held to the same declared edge as a desk button - and the move fires the same mail and publishes the same event, stamped `deadline`.
|
||||
- Documentation moved out of the README into `docs/`: architecture, content reference, state machines, access, operations.
|
||||
- A page says nothing until it has something to say. The Suspense fallback on a question was the whole served page - no header, no wordmark - so the first and only thing a visitor or a crawler read was the word "loading…", which is true and is not about them. It now holds the space and stays quiet, with `aria-busy` for a reader that would otherwise find an empty document.
|
||||
- Gone: the `gitea_org_repos` resource kind, which no site ever named. `gitea_starred` and `gitea_releases` stay.
|
||||
|
||||
## 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
|
||||
@@ -252,7 +367,7 @@ content repos pin the tag they run (see the end of this file).
|
||||
- README: what the engine provides
|
||||
- Feature icons: masked span painted by currentColor, not <img>
|
||||
- Responsible note: one line, same hue and size as the hero description
|
||||
- Responsible note as <small>: meta information, dimmer than the description
|
||||
- Responsible note as `<small>`: meta information, dimmer than the description
|
||||
- Responsible note back to the footer, above the question nav
|
||||
- Bound inputs: a field that loads its value from a sibling-parameterized resource
|
||||
- add some more width to our alternatives
|
||||
@@ -279,14 +394,14 @@ content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## What each site runs
|
||||
|
||||
Read from the hosts on 2026-09-23; a rollout is a pin bump in the
|
||||
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`), or a manual
|
||||
tarball deploy on kasse.
|
||||
Read from the hosts on 2026-09-28; a rollout is a pin bump in the
|
||||
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`) - on both
|
||||
hosts now, since kasse's runner deploys its own instances.
|
||||
|
||||
| Site | Host | Instance | Version |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| uhhm.no | ergo | `app@uhhm-portal` | v0.5.2 |
|
||||
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
|
||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
|
||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
|
||||
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.10, memberships |
|
||||
|
||||
Generated
+213
-4
@@ -132,9 +132,9 @@ checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -794,7 +794,7 @@ dependencies = [
|
||||
"linked-hash-map",
|
||||
"linked_hash_set",
|
||||
"miette",
|
||||
"nonempty",
|
||||
"nonempty 0.12.0",
|
||||
"ref-cast",
|
||||
"regex",
|
||||
"rustc-literal-escaper",
|
||||
@@ -1060,6 +1060,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -1161,6 +1167,20 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1517,6 +1537,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty 0.7.0",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1594,6 +1624,12 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
@@ -1642,9 +1678,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1695,6 +1733,29 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.5",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -1756,6 +1817,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1993,6 +2060,19 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.11.0",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2887,6 +2967,12 @@ dependencies = [
|
||||
"signatory",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.12.0"
|
||||
@@ -2896,6 +2982,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3265,11 +3357,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.3.41"
|
||||
version = "0.5.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -3297,6 +3390,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -3468,6 +3562,21 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -3667,6 +3776,15 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reactive_graph"
|
||||
version = "0.2.14"
|
||||
@@ -4585,6 +4703,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -4906,6 +5033,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -4984,6 +5122,35 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"h2 0.4.15",
|
||||
"http 1.4.2",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.11.0",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2 0.6.5",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -4992,9 +5159,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 2.14.0",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -5106,6 +5276,23 @@ dependencies = [
|
||||
"tower-sessions-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower_governor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"governor",
|
||||
"http 1.4.2",
|
||||
"pin-project",
|
||||
"thiserror 2.0.19",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -5528,6 +5715,22 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5537,6 +5740,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.3.41"
|
||||
version = "0.5.11"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -63,6 +63,8 @@ web-sys = { version = "0.3", features = [
|
||||
"Response",
|
||||
"Window",
|
||||
], optional = true }
|
||||
tower_governor = { version = "0.8.0", optional = true }
|
||||
async-trait = { version = "0.1.92", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -81,6 +83,8 @@ ssr = [
|
||||
"dep:leptos_axum",
|
||||
"dep:axum",
|
||||
"dep:aws-sdk-s3",
|
||||
"dep:tower_governor",
|
||||
"dep:async-trait",
|
||||
"dep:tokio",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
|
||||
@@ -1,190 +1,51 @@
|
||||
# portal
|
||||
|
||||
A content-driven question engine: one Rust binary that turns a Git
|
||||
repo of YAML into a live site — pages, forms, review desks, and state
|
||||
repo of YAML into a live site — pages, forms, review desks and state
|
||||
machines, with a durable event-sourced record of every answer
|
||||
underneath. The engine special-cases nothing: everything a site
|
||||
serves is declared in its content repo, and the same build serves any
|
||||
number of sites.
|
||||
underneath.
|
||||
|
||||
Point an instance at a content repo and that repo *is* the site:
|
||||
pages, copy, state graphs, branding, and even the landing hero
|
||||
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
|
||||
hero.js}` — a JavaScript module the content repo ships, served
|
||||
same-origin at `/site/<path>`, that portal mounts at parse time and
|
||||
stops on navigation; the YES canvas and redoal's sine swings are both
|
||||
content, not engine). Content
|
||||
pushes hot-reload every running instance; instances differ only by
|
||||
env vars. [uhhm.no](https://uhhm.no)
|
||||
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
|
||||
[redoal.com](https://redoal.com)
|
||||
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
|
||||
two faces of the same binary, deployed from the same release
|
||||
artifact.
|
||||
The engine special-cases nothing. Everything a site serves is declared
|
||||
in its content repo, content pushes hot-reload every running instance,
|
||||
and the same build serves any number of sites: instances differ only
|
||||
by environment variables. [uhhm.no](https://uhhm.no) and
|
||||
[redoal.com](https://redoal.com) are two faces of the same binary from
|
||||
the same release artifact.
|
||||
|
||||
It composes with the surrounding stack rather than bundling it: Gitea
|
||||
hosts and serves the content, NATS JetStream stores events and
|
||||
projections, Kanidm provides identity, and anything downstream
|
||||
(automations, newsletters, onboarding) subscribes to the answer
|
||||
stream.
|
||||
It composes with the surrounding stack rather than bundling it. Gitea
|
||||
hosts the content, NATS JetStream stores the events and projections,
|
||||
Kanidm provides identity, and anything downstream — automations,
|
||||
newsletters, onboarding — subscribes to the answer stream.
|
||||
|
||||
## What the engine provides
|
||||
## Documentation
|
||||
|
||||
- **Content-driven pages** (`src/content/`, `src/app.rs`): YAML
|
||||
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
|
||||
a bad push keeps the last-good content serving. The `questions/`
|
||||
tree IS the router — file paths become URLs, `_section.yaml`
|
||||
applies criteria to a whole directory, `[name].yaml` pages serve
|
||||
any `/dir/<value>` with the segment fed into resource keys, and
|
||||
`requires_chain` gates a page on verifiable answer provenance next
|
||||
to `qualifies`' Kanidm-group identity gate (see
|
||||
`docs/design/filesystem-routes.md`).
|
||||
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
|
||||
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
|
||||
redoal repo) - the input shows the key's own decode under the
|
||||
stroke, who is at a similar shape now, and *places* with recordings
|
||||
the visitor can pick to make that key theirs; a voice field records
|
||||
in the browser and leaves the audio at a key. Actions may be
|
||||
templated from the answer (`/shape/{curve.key}`) onto dynamic
|
||||
pages, whose URL segment also fills `value: "{key}"` presets and
|
||||
`url` resource sources; resource items with an https `audio` field
|
||||
render a player.
|
||||
- **Announced pages** (`src/announce.rs`): a question with an
|
||||
`event: {starts, duration, place}` window is announced in the
|
||||
header (not the footer nav) while the window is open, becomes a
|
||||
followup when it closes, and its record in the runtime-owned
|
||||
`portal_events` bucket moves to `awaiting_summary` - a "post what
|
||||
happened" task any review desk can read, published on NATS like a
|
||||
decision. A one-minute sweeper keeps it all idempotent.
|
||||
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
|
||||
declares each bucket's states and legal transitions; the engine
|
||||
replays a record's event history and refuses undeclared moves, with
|
||||
optimistic concurrency (CAS on the event log's sequence) against
|
||||
racing decisions. An empty history reseeds from the KV projection,
|
||||
so wiping the event stream strands nothing.
|
||||
- **Durable events + projections** (`src/events/`, `src/answers.rs`):
|
||||
every submission and decision appends to a JetStream event log and
|
||||
projects into a NATS KV bucket pages read back; every one also
|
||||
publishes on `portal.answers.submitted` for automations (n8n) to
|
||||
react to.
|
||||
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
||||
parent(s), so a visitor's path through the questions is a verifiable
|
||||
lineage; `?chain=` links carry it, and self-service transitions
|
||||
(unsubscribe) authorize by holding one. The question nav reads it
|
||||
too: a `followup` page is offered only once the visitor has earned
|
||||
it - the chain verifiably answered the alternative that leads there,
|
||||
or it hangs off an earned page by a link that records nothing. A
|
||||
page behind an answer they have not given is never offered.
|
||||
- **One access policy** (`src/access.rs`): who may do what to what is
|
||||
compiled from content on every load into a Cedar policy set - `qualifies`
|
||||
on a page, `requires_group`/`public` on a resource, a desk's transitions,
|
||||
a `self_transition` by link and email, an `invite` - and every server
|
||||
entry point asks that one policy. Decisions carry the rule ids that
|
||||
granted them; refusals and every mutation are published on
|
||||
`portal.access.decided`, and other apps ask the same question over
|
||||
request-reply on `portal.access.<site>.may`. Identity stays Kanidm's.
|
||||
- **Onboarding as a form** (`src/people.rs`): an alternative with
|
||||
`invite: {group}` on a gated page creates the person in Kanidm, records
|
||||
their email, adds them to the group and mints a one-time link to set
|
||||
their own credentials, through a service account in
|
||||
`idm_people_on_boarding` (`KANIDM_API_TOKEN`). The result lands in the
|
||||
`portal_invites` bucket for the inviter's desk and on
|
||||
`portal.people.invited` for whatever sends the mail.
|
||||
- **Business needs as a checkable spec** (in
|
||||
[uhhm/iris](https://project.uhhm.no/uhhm/iris)): a content repo may
|
||||
ship a `needs.yaml` saying who wants what, which bucket the answer
|
||||
must land in, and which group has to carry it to a finished state.
|
||||
`iris check` proves the site meets it. The runtime never reads the
|
||||
file; portal only provides the content types iris reads.
|
||||
- **Live resources** (`src/resource.rs`): content can pull a KV
|
||||
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
||||
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
||||
- **Input kinds as content**: a requirement's `type:` picks the
|
||||
widget — plain fields, a rich-text editor, or a `gesture` drawing
|
||||
canvas that can connect to a redoal relay so similar strokes echo
|
||||
between visitors live. Submitted values are arbitrary JSON; the
|
||||
engine records what the widget produced.
|
||||
- **Review desks**: any Kv resource with `transitions` renders rows
|
||||
with per-state action buttons and one shared confirm per
|
||||
alternative — owners walk records through their graphs without
|
||||
bespoke UI per bucket.
|
||||
Everything is under [`docs/`](docs/README.md):
|
||||
|
||||
## Binaries
|
||||
|
||||
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
|
||||
- `iris` — the lint, from [uhhm/iris](https://project.uhhm.no/uhhm/iris),
|
||||
which depends on this crate as a library pinned to a release tag and
|
||||
carries the same version: the publish job here re-pins, tests and tags
|
||||
iris for every portal release.
|
||||
Every release tarball ships that `iris` binary
|
||||
too, so each content repo's CI can
|
||||
lint with the exact portal version its instance runs:
|
||||
`iris check --path <dir>`.
|
||||
- [Architecture](docs/architecture.md) — what the engine is made of.
|
||||
- [Content reference](docs/content-reference.md) — every key a content
|
||||
repo may write.
|
||||
- [State machines](docs/state-machines.md) — `aggregates.yaml`.
|
||||
- [Access](docs/access.md) — the policy compiled from content.
|
||||
- [Operations](docs/operations.md) — releasing, deploying, adding a site.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cargo leptos build # full app (server + wasm)
|
||||
cargo test --features ssr # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL
|
||||
cargo leptos build # server + wasm
|
||||
cargo test --features ssr # tests/jetstream.rs needs PORTAL_TEST_NATS_URL
|
||||
```
|
||||
|
||||
Runtime configuration is env vars (see `src/main.rs`, and each
|
||||
content repo's `.gitea/workflows/deploy.yml` for the values in
|
||||
production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
|
||||
(`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
|
||||
`GITEA_API_TOKEN` for authenticated resource pulls,
|
||||
`AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
|
||||
|
||||
## Release and deploy
|
||||
|
||||
Portal doesn't deploy itself — it publishes versions, and each site
|
||||
decides when to take one. The whole day-to-day surface is two
|
||||
commands:
|
||||
|
||||
**Cut a release** (here):
|
||||
Content is linted by [iris](https://project.uhhm.no/uhhm/iris), which
|
||||
depends on this crate and carries the same version, so a content repo
|
||||
can check itself against the exact portal its instance runs:
|
||||
|
||||
```sh
|
||||
cargo release patch # or minor / major
|
||||
iris check --path questions
|
||||
```
|
||||
|
||||
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
|
||||
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
|
||||
attaches `portal-v<version>.tar.gz` (`portal` + `iris` +
|
||||
`hash.txt` + `site/`) to the Gitea release. Pkg files are
|
||||
content-hashed (`hash-files = true`; instances run with
|
||||
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
|
||||
stale cached bundle with new wasm. Plain pushes to `main` only run the
|
||||
tests (`test.yml`) — nothing reaches production from this repo.
|
||||
## Releasing
|
||||
|
||||
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
|
||||
heading for the new version. That section becomes the Gitea release
|
||||
notes, and the table at the end of the file records which version each
|
||||
site runs; update it when a pin moves.
|
||||
|
||||
**Roll it out** (in a content repo): edit one line in that repo's
|
||||
`.gitea/workflows/deploy.yml` —
|
||||
|
||||
```yaml
|
||||
env:
|
||||
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
|
||||
```
|
||||
|
||||
Its CI downloads the pinned artifact, ships
|
||||
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
|
||||
instance env from that repo's own Actions variables/secrets, restarts
|
||||
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
|
||||
commit, so reverting a bad version is `git revert` + push. Sites
|
||||
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
|
||||
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
|
||||
:3020) can pin different versions.
|
||||
|
||||
Content changes never come through any of this — they hot-reload
|
||||
live from the content repos over NATS.
|
||||
|
||||
**Add a site**: new content repo with a copy of an existing
|
||||
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
|
||||
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
|
||||
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
|
||||
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
|
||||
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
|
||||
content repo handles all branding; no portal changes needed.
|
||||
`cargo release patch` tags and pushes; CI builds the tarball and
|
||||
attaches it to the Gitea release. A site takes it by bumping
|
||||
`PORTAL_RELEASE` in its own content repo. See
|
||||
[operations](docs/operations.md).
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# The docs as a site: portal.uhhm.no, built by .gitea/workflows/docs.yml
|
||||
# from docs/ with mdBook and put to git-pages (see uhhm/infrastructure
|
||||
# PAGES.md). `mdbook serve` here previews it.
|
||||
[book]
|
||||
title = "portal"
|
||||
description = "A Git repo of YAML becomes a running site: pages, desks, cases, people."
|
||||
authors = ["uhhm"]
|
||||
language = "en"
|
||||
src = "docs"
|
||||
|
||||
[build]
|
||||
build-dir = "target/book"
|
||||
create-missing = false
|
||||
|
||||
[output.html]
|
||||
default-theme = "light"
|
||||
preferred-dark-theme = "navy"
|
||||
git-repository-url = "https://project.uhhm.no/uhhm/portal"
|
||||
site-url = "/"
|
||||
no-section-label = true
|
||||
|
||||
[output.html.search]
|
||||
limit-results = 20
|
||||
@@ -0,0 +1,25 @@
|
||||
# portal documentation
|
||||
|
||||
Portal turns a Git repo of YAML into a running site. Almost everything
|
||||
here is about that YAML: what a content repo may say, and what the
|
||||
engine does with it.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Getting started](getting-started.md) | Bringing your people in: from an empty Kanidm to everyone at their own desk, from what the content says. |
|
||||
| [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. |
|
||||
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
|
||||
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
|
||||
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
|
||||
| [Storylines](storylines.md) | The same site told several ways: lists of texts, one storyline per visitor, counted per storyline. |
|
||||
| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
|
||||
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
|
||||
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
||||
|
||||
Two repos in the same family carry their own docs:
|
||||
|
||||
- [uhhm/iris](https://project.uhhm.no/uhhm/iris) — the lint. Versioned
|
||||
as portal and released by portal's publish job; it is the thing a
|
||||
content repo's CI runs before anything reaches a site.
|
||||
- [uhhm/gdo](https://project.uhhm.no/uhhm/gdo) — the mailer. Delivers
|
||||
what content asked to be sent, and carries replies back.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Summary
|
||||
|
||||
[portal](README.md)
|
||||
|
||||
# Using it
|
||||
|
||||
- [Getting started](getting-started.md)
|
||||
- [Content reference](content-reference.md)
|
||||
- [State machines](state-machines.md)
|
||||
- [Access](access.md)
|
||||
- [Storylines](storylines.md)
|
||||
- [Memberships](memberships.md)
|
||||
- [Operations](operations.md)
|
||||
|
||||
# How it is made
|
||||
|
||||
- [Architecture](architecture.md)
|
||||
- [Filesystem routes](design/filesystem-routes.md)
|
||||
|
||||
---
|
||||
|
||||
[Changelog](changelog.md)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Access
|
||||
|
||||
Who may do what to what is compiled from content on every load into
|
||||
one Cedar policy set, and every server entry point asks that one
|
||||
policy. Identity stays Kanidm's; portal only decides.
|
||||
|
||||
## Where the groups come from
|
||||
|
||||
The policy names groups; it does not hold them. A `User` entity's
|
||||
parents are the person's groups, and those come from the sign-in: the
|
||||
`groups` claim the provider sent, joined with the person's active
|
||||
[memberships](memberships.md) when portal keeps them itself. Cedar
|
||||
sees one list either way.
|
||||
|
||||
## What content compiles to
|
||||
|
||||
| content | rule |
|
||||
|---|---|
|
||||
| a page with no `qualifies` | anyone may open and submit |
|
||||
| `qualifies: case_officers` | that group may open and submit |
|
||||
| `requires_chain: /start` | the visitor's verified answer lineage must reach it |
|
||||
| a resource with `public: true` | anyone may read it |
|
||||
| a resource with `requires_group: g` | that group may read it |
|
||||
| a desk resource's `transitions` | that group may make those moves |
|
||||
| an alternative's `self_transition` | the submitter may make it, from each state in `from`, with their link and their matching email |
|
||||
| an alternative's `invite: {group}` | the page's own group may invite into that group |
|
||||
|
||||
Mutations always need a group, whatever `public` says. A resource with
|
||||
neither `public` nor `requires_group` is unreachable rather than open:
|
||||
the policy fails closed.
|
||||
|
||||
## Reading the policy
|
||||
|
||||
```sh
|
||||
iris check --path questions --access
|
||||
```
|
||||
|
||||
prints every rule the content compiled to, as a table of who may do
|
||||
what from where to where. It is the fastest way to find a rule a
|
||||
content repo did not mean to write — a refusal available on the day a
|
||||
case arrives, a board that can decide a case it never received.
|
||||
|
||||
## Asking it
|
||||
|
||||
Every decision carries the rule ids that granted it. Refusals and
|
||||
every mutation are published on `portal.access.decided`.
|
||||
|
||||
Other applications ask the same question over NATS request-reply on
|
||||
`portal.access.<site>.may`, with the same principal, action, resource
|
||||
and context the server uses internally — so an automation acting as a
|
||||
group gets exactly the answer a person in that group would.
|
||||
|
||||
A principal may also name a person by `sub`, `phone` or `email` and
|
||||
list no groups; portal then looks their [memberships](memberships.md)
|
||||
up before deciding. That is what makes the policy usable for someone
|
||||
who has never opened the site.
|
||||
|
||||
## The submitter
|
||||
|
||||
A person who sent a form has no account. Their credential is the link:
|
||||
an unguessable chain hash, plus the email they gave, which the policy
|
||||
compares against the one stored on the record.
|
||||
|
||||
What they may do, and from where, is `self_transition.from`. Its
|
||||
default is the initial state alone. A refusal reads "not found" while
|
||||
the record is somewhere the move could have been made from — so the
|
||||
endpoint cannot be used to discover which records or addresses exist —
|
||||
and "already processed" once the case has moved past it.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Architecture
|
||||
|
||||
One Rust binary (Leptos SSR + hydrate, Axum underneath) serving any
|
||||
number of sites. The engine special-cases nothing: every page, form,
|
||||
desk and state graph a site serves is declared in its content repo,
|
||||
and instances differ only by environment variables.
|
||||
|
||||
It composes with the surrounding stack rather than bundling it. Gitea
|
||||
hosts the content, NATS JetStream stores the events and projections,
|
||||
Kanidm provides identity, and anything downstream subscribes to the
|
||||
answer stream.
|
||||
|
||||
## The pieces
|
||||
|
||||
**Content** (`src/content/`) — YAML loaded from Gitea at boot and
|
||||
hot-swapped on a NATS reload signal. A bad push keeps the last-good
|
||||
content serving. The `questions/` tree is the router: file paths
|
||||
become URLs, `_section.yaml` applies to a whole directory, and
|
||||
`[name].yaml` serves any `/dir/<value>` with the segment fed into the
|
||||
page's resource keys. See [filesystem routes](design/filesystem-routes.md).
|
||||
|
||||
**The lint** (`src/content/validate.rs`) — every rule a content repo
|
||||
is held to. It runs at boot, on every hot reload, and in a content
|
||||
repo's CI through [iris](https://project.uhhm.no/uhhm/iris), which
|
||||
depends on this crate and so applies exactly the version the instance
|
||||
runs.
|
||||
|
||||
**State machines** (`src/aggregates/`) — `aggregates.yaml` declares
|
||||
each bucket's states and legal moves. A record's state is replayed
|
||||
from its own event history, undeclared moves are refused, and racing
|
||||
decisions are settled by CAS on the log's sequence. An empty history
|
||||
reseeds from the KV projection, so wiping the event stream strands
|
||||
nothing. See [state machines](state-machines.md).
|
||||
|
||||
**Events and projections** (`src/events/`, `src/answers.rs`) — every
|
||||
submission and decision appends to a JetStream log and projects into
|
||||
a NATS KV bucket that pages read back, and publishes on
|
||||
`portal.answers.submitted` for automations to react to.
|
||||
|
||||
**Answer chains** (`src/chain.rs`) — each submission hashes its
|
||||
parents, so a visitor's path through the questions is a verifiable
|
||||
lineage. `?chain=` links carry it; a `followup` page is offered only
|
||||
once the chain has earned it, and a page behind an answer somebody has
|
||||
not given is never offered.
|
||||
|
||||
**Access** (`src/access.rs`) — who may do what to what, compiled from
|
||||
content on every load into one Cedar policy set that every server
|
||||
entry point asks. See [access](access.md).
|
||||
|
||||
**People** (`src/people.rs`) — onboarding as a form. An alternative
|
||||
with `invite: {group}` creates the person in Kanidm, records their
|
||||
email, adds them to the group and mints a one-time credential link. A
|
||||
state may also grant a group on its own (`grants:`), so an approval
|
||||
that means membership needs no second step.
|
||||
|
||||
**Mail** (`src/mail.rs`) — a state may declare the mail its arrival
|
||||
sends. Portal renders it and publishes on `portal.mail.send`;
|
||||
[gdo](https://project.uhhm.no/uhhm/gdo) delivers it and carries
|
||||
replies back on `portal.mail.received`, where they become notes beside
|
||||
the record. A note never moves a case.
|
||||
|
||||
**Sweepers** (`src/announce.rs`, `src/deadlines.rs`) — the two things
|
||||
that move without a person. An announced page's window closes and its
|
||||
record becomes a "post what happened" task; a state with a deadline
|
||||
waits out its days and then makes the move itself. Both go through the
|
||||
same transition path as a desk button and publish the same event.
|
||||
|
||||
**Resources** (`src/resource.rs`) — content can read a KV bucket,
|
||||
Gitea starred repos, org repos or releases, or any public JSON URL
|
||||
(SSRF fail-closed), reshaped by a content-declared `jq` filter. What a
|
||||
resource reads is never a client-supplied parameter: the client names
|
||||
a page and a feature, never a bucket or URL.
|
||||
|
||||
**Maps** (`src/maps.rs`) — MapLibre is vendored and every tile, style
|
||||
and still image is proxied through portal, so a page with a map makes
|
||||
no third-party request and needs no consent banner.
|
||||
|
||||
**Desks** — any Kv resource with `transitions` renders rows with
|
||||
per-state buttons and one shared confirm. Owners walk records through
|
||||
their graphs with no bespoke UI per bucket.
|
||||
|
||||
## Binaries
|
||||
|
||||
- `portal` — the server.
|
||||
- `iris` — the lint, from its own repo, shipped in every release
|
||||
tarball so a content repo's CI can lint with the exact version its
|
||||
instance runs.
|
||||
|
||||
**Sessions** (`src/sessions.rs`) — in the same JetStream KV as
|
||||
everything else, so a restart does not sign anybody out. The bucket
|
||||
carries a `max_age` so NATS collects what nobody comes back for, and
|
||||
each record's own `expiry_date` is what decides on the way out.
|
||||
@@ -0,0 +1,258 @@
|
||||
# Content reference
|
||||
|
||||
A content repo is the site. This is everything it may say.
|
||||
|
||||
```
|
||||
site.yaml branding, language, mail
|
||||
aggregates.yaml the state machine of every bucket
|
||||
needs.yaml what the site is for (read by iris, never by portal)
|
||||
questions/
|
||||
index.yaml the front page, served at /
|
||||
about.yaml served at /about
|
||||
review/
|
||||
_section.yaml applies to everything under /review
|
||||
index.yaml served at /review
|
||||
[record].yaml serves /review/<anything>
|
||||
```
|
||||
|
||||
Nothing here is required except a `questions/` tree with something in
|
||||
it. Portal's lint refuses content it cannot serve honestly, at boot,
|
||||
on every hot reload, and in CI through `iris check`.
|
||||
|
||||
## site.yaml
|
||||
|
||||
```yaml
|
||||
title: Vestmark kommune # the browser title and the header
|
||||
wordmark: /site/logo.svg # an image instead of the title
|
||||
wordmark_invert: true # flip it in dark mode
|
||||
wordmark_height: 2.4 # rem
|
||||
lang: no # the interface language: en or no
|
||||
stylesheet: /site/theme.css # served same-origin from the repo
|
||||
favicon: /site/icon.svg
|
||||
hero: { kind: module, module: hero.js } # or { kind: plain }
|
||||
mail:
|
||||
from: Vestmark kommune <post@vestmark.example>
|
||||
reply_to: post@vestmark.example
|
||||
invite: { subject: "...", body: "..." } # rewords the built-in invite mail
|
||||
```
|
||||
|
||||
`mail.from` is required as soon as any state declares a mail.
|
||||
|
||||
Any text a person reads (`name`, `description`, `label`,
|
||||
`placeholder`, a mail's `subject` and `body`, `consequence`,
|
||||
`encouragements`) may be a list instead of one string: one entry per
|
||||
[storyline](storylines.md), all lists the same length.
|
||||
|
||||
## A question
|
||||
|
||||
One YAML file is one page.
|
||||
|
||||
```yaml
|
||||
id: /apply # defaults to the file's path
|
||||
name: Apply to build
|
||||
description: What we need from you.
|
||||
qualifies: case_officers # a Kanidm group may open this page
|
||||
requires_chain: /start # or: an answer they must already have given
|
||||
followup: true # keep it out of the nav until earned
|
||||
responsible: { name: Solveig, contact: post@vestmark.example }
|
||||
# or a group's: no account is made for the inbox, and any signed-in
|
||||
# member of the group may suggest a change to the page
|
||||
responsible: { name: The board, contact: board@vestmark.example, group: board }
|
||||
event: # this page announces an occasion
|
||||
starts: 2026-10-14T18:00
|
||||
duration: 2h
|
||||
place: The town hall
|
||||
place: # this page is about somewhere
|
||||
lat: 59.8938
|
||||
lon: 11.0332
|
||||
zoom: 15
|
||||
name: Tomter
|
||||
alternatives: [ ... ]
|
||||
```
|
||||
|
||||
`qualifies`, `requires_chain` and `responsible` are inherited from the
|
||||
nearest `_section.yaml`. `event` makes a page an occasion with a time;
|
||||
`place` makes it a place, drawing a still map under the question and
|
||||
opening any `type: location` field on the page there rather than on
|
||||
the whole world.
|
||||
|
||||
## An alternative
|
||||
|
||||
One choice. Never two actions behind one heading: if two things can be
|
||||
done, they are two alternatives.
|
||||
|
||||
```yaml
|
||||
alternatives:
|
||||
- name: Send the drawings
|
||||
description: We answer within twelve weeks.
|
||||
action: /sent # the page to go to; /shape/{field} templates
|
||||
record_as: applications # the bucket the answer lands in
|
||||
disabled: false
|
||||
consequence: [What happens next]
|
||||
encouragements: [It takes two minutes]
|
||||
images: [/site/drawing.png]
|
||||
features: [ ... ]
|
||||
```
|
||||
|
||||
Three things an alternative may do instead of, or beside, recording an
|
||||
answer:
|
||||
|
||||
```yaml
|
||||
self_transition: # the person moves their own record
|
||||
bucket: applications
|
||||
to: complete
|
||||
from: [open, incomplete] # defaults to the initial state alone
|
||||
label: Send them
|
||||
invite: { group: case_officers } # onboard someone into Kanidm
|
||||
```
|
||||
|
||||
`self_transition.from` is what keeps a person in dialogue with their
|
||||
case: an applicant written to for a missing drawing answers from
|
||||
`incomplete`, and may withdraw from wherever the case has got to.
|
||||
Omitted, it is the initial state alone, which is what a one-shot
|
||||
unsubscribe link means. Each state listed becomes its own access rule,
|
||||
and the lint refuses a `from -> to` the state machine does not declare.
|
||||
|
||||
An `invite` needs a required `name` and `email` on the same
|
||||
alternative, and the inviting group must be allowed to invite that
|
||||
group by the policy.
|
||||
|
||||
## A feature
|
||||
|
||||
What an alternative offers, explained. A feature that is only layout
|
||||
is a feature wasted: each one says something true about the choice.
|
||||
|
||||
```yaml
|
||||
features:
|
||||
- name: What you need
|
||||
description: The case number from the letter.
|
||||
icon: document
|
||||
color: "#8ec2c0"
|
||||
requirements: [ ... ] # form fields
|
||||
resource: { ... } # live data
|
||||
```
|
||||
|
||||
## A requirement (a form field)
|
||||
|
||||
```yaml
|
||||
requirements:
|
||||
- name: email # snake_case; this is the stored key
|
||||
label: Your email
|
||||
placeholder: you@example.no
|
||||
type: email
|
||||
optional: false
|
||||
value: "{key}" # a preset; a dynamic page's segment fills it
|
||||
```
|
||||
|
||||
`value: "{user.email}"` or `"{user.name}"` is the signed-in person's
|
||||
own, and a field it fills is carried rather than asked: someone the
|
||||
site knows is not asked who they are. A visitor gets the field, empty.
|
||||
|
||||
`type` is one of:
|
||||
|
||||
| type | what it is |
|
||||
|---|---|
|
||||
| `text` | the default |
|
||||
| `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type |
|
||||
| `textarea` | several lines |
|
||||
| `prosekit` | a rich-text editor |
|
||||
| `select` | one choice, or `multiple: true` for any number |
|
||||
| `file` | an upload, with `accept:` and `multiple:` |
|
||||
| `location` | a pin on a map, stored as `{lat, lon, zoom}` |
|
||||
| `record` | the record this one belongs to |
|
||||
| `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay |
|
||||
| `hidden` | a carrier value, no label row |
|
||||
| `module` | a widget the content repo ships |
|
||||
|
||||
A `select` takes either inline `options: [...]` or a `resource:` to
|
||||
draw them from, with `id_field:` naming each item's stable id.
|
||||
|
||||
A field may load its value from a resource when a sibling changes:
|
||||
|
||||
```yaml
|
||||
- name: contents
|
||||
type: textarea
|
||||
bind:
|
||||
field: path # the sibling to watch
|
||||
resource: { source: { kind: url, url: "https://git.example/raw/{path}" } }
|
||||
```
|
||||
|
||||
### type: record
|
||||
|
||||
How one case belongs to another — an objection to the application it
|
||||
objects to, an appeal to the decision it appeals.
|
||||
|
||||
```yaml
|
||||
- name: case
|
||||
type: record
|
||||
of: applications # the bucket the other record is in
|
||||
value: "{key}" # arrives on the link, on a dynamic page
|
||||
```
|
||||
|
||||
Nobody types one of these: the stored value is the parent's record id,
|
||||
which is a chain hash. It arrives the way it was given out, so the
|
||||
field carries rather than asks, and renders hidden. A submission
|
||||
naming a record that is not there is refused, so a reference in a
|
||||
stored record is one that resolved at least once.
|
||||
|
||||
The other half is a listing that asks for the children:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: objections }
|
||||
requires_group: case_officers
|
||||
where: { field: case, equals: "{key}" }
|
||||
```
|
||||
|
||||
And the way there from a list: `link` puts a link on every listed
|
||||
record to a page about it, with `{id}` for the record's id and
|
||||
`{field}` for any of its fields. A notice on a public board links to
|
||||
`/oppslag/{id}`, the dynamic page `/oppslag/[key]` whose form carries
|
||||
the notice's id back in with `value: "{key}"`:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: oppslag }
|
||||
public: true
|
||||
link: { to: "/oppslag/{id}", label: Jeg kan låne ut eller låne denne }
|
||||
```
|
||||
|
||||
## A resource
|
||||
|
||||
Live data, declared by content and read by the server. What a resource
|
||||
reads is never a client-supplied parameter.
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: applications }
|
||||
key: abc123 # one record instead of the list
|
||||
public: true # or requires_group: case_officers
|
||||
states: [approved, refused] # only these; empty means all
|
||||
where: { field: case, equals: "{key}" }
|
||||
jq: ".[] | {name, url: .html_url}"
|
||||
empty: Nothing decided yet.
|
||||
link: { to: "/oppslag/{id}", label: Answer this } # a page per record
|
||||
transitions: # the desk's buttons
|
||||
- { from: complete, to: on_hearing, label: Send it out }
|
||||
```
|
||||
|
||||
`source.kind` is one of `kv`, `gitea_starred`,
|
||||
`gitea_releases`, `url`.
|
||||
|
||||
A spec with neither `public: true` nor `requires_group` is
|
||||
unreachable: reads fail closed, and mutations always need a group
|
||||
whatever `public` says.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
spec and still sees all of it. A record that has no such field never
|
||||
matches: "everything that did not say" is not something a page can ask
|
||||
for by accident.
|
||||
|
||||
## needs.yaml
|
||||
|
||||
What the site is for, as a checkable spec: who wants what, which
|
||||
bucket the answer must land in, which group carries it to a finished
|
||||
state. The runtime never reads it. `iris check` proves the site meets
|
||||
it, and `iris replay` runs cases through the real engine.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Getting started: bringing your people in
|
||||
|
||||
A site is run by a handful of people: whoever answers for each question
|
||||
it asks, and whoever works each desk. This is how they get in - from
|
||||
an empty Kanidm to everyone signed in at their own desk - without
|
||||
anyone handing out passwords or running an admin tool per person.
|
||||
|
||||
The idea in one line: **the content says who is involved, and portal
|
||||
makes their accounts.** A page names who is responsible for it; a
|
||||
directory is gated on a group; a desk may invite into groups. Portal
|
||||
reads that and does the rest at start.
|
||||
|
||||
## What you need
|
||||
|
||||
- A content repo, and a portal instance running it
|
||||
([Operations](operations.md) has adding a site).
|
||||
- A Kanidm of the site's own. Group names below are exactly the ones
|
||||
the content uses, with no prefix; that is only safe when the Kanidm
|
||||
serves this site alone. (Or no Kanidm at all: with
|
||||
`PEOPLE_BACKEND=memberships` portal keeps the groups itself and any
|
||||
OIDC provider that can say who someone is will do; see
|
||||
[Memberships](memberships.md). The rest of this page is the Kanidm
|
||||
way.)
|
||||
- Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without
|
||||
it accounts are still made, but nobody hears about them.
|
||||
|
||||
## 1. Say who is involved, in the content
|
||||
|
||||
Three things in the content decide who needs an account:
|
||||
|
||||
| content | who | what they get |
|
||||
|---|---|---|
|
||||
| `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
|
||||
| `responsible: { name, contact, group }` | a group answering for it | nothing: the inbox is not a person; the group's members have the desk, and each of them may suggest changes to the page |
|
||||
| `qualifies: styret` on a page or `_section.yaml` | members of that group | the pages and desks under it |
|
||||
| `invite: { group: kasserer }` on a desk page | the desk's own group | the right to bring people into `kasserer` |
|
||||
|
||||
The desk whose group may invite into the most groups is the site's most
|
||||
privileged one; on a vel that is the board. Portal works that out from
|
||||
the content (see step 4).
|
||||
|
||||
## 2. Set up Kanidm for portal
|
||||
|
||||
Portal needs four things from Kanidm. The
|
||||
[tomtervel/infrastructure](https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure)
|
||||
repo's `kanidm-setup.sh` does all of them and is a good starting point;
|
||||
by hand, as `idm_admin`:
|
||||
|
||||
```sh
|
||||
K="kanidm -D idm_admin -H https://id.example.no"
|
||||
|
||||
# An onboarding account: may create people and issue a first password
|
||||
# link, nothing more. pii_read lets an invite find someone who already
|
||||
# has an account by their email instead of making a second one.
|
||||
$K service-account create portal-onboarding "Portal onboarding" idm_admin
|
||||
$K group add-members idm_people_on_boarding portal-onboarding
|
||||
$K group add-members idm_people_pii_read portal-onboarding
|
||||
|
||||
# One group per `qualifies:` in the content, managed by that account:
|
||||
# adding a member to a group takes the right to manage it.
|
||||
for g in styret kasserer trafikk; do
|
||||
$K group create "$g" portal-onboarding
|
||||
done
|
||||
|
||||
# The sign-in client. Everyone in this Kanidm may sign in; what they
|
||||
# see comes from their groups, in the `groups` claim.
|
||||
$K system oauth2 create portal "My site" https://site.example.no
|
||||
$K system oauth2 add-redirect-url portal https://site.example.no/auth/callback
|
||||
$K system oauth2 update-scope-map portal idm_all_persons openid profile email
|
||||
for g in styret kasserer trafikk; do
|
||||
$K system oauth2 update-claim-map portal groups "$g" "$g"
|
||||
done
|
||||
$K system oauth2 update-claim-map-join portal groups array
|
||||
|
||||
# The onboarding token, read-write, shown once: into portal's env.
|
||||
$K service-account api-token generate portal-onboarding portal --readwrite
|
||||
```
|
||||
|
||||
Then portal's environment:
|
||||
|
||||
| variable | value |
|
||||
|---|---|
|
||||
| `KANIDM_URL` | `https://id.example.no` |
|
||||
| `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | the client above (`show-basic-secret`) |
|
||||
| `KANIDM_API_TOKEN` | the onboarding token |
|
||||
|
||||
## 3. Name the first person
|
||||
|
||||
Someone has to be first: every later person is invited from a desk,
|
||||
and on an empty Kanidm there is no desk anyone can open yet.
|
||||
|
||||
| variable | value |
|
||||
|---|---|
|
||||
| `SEED_ADMIN_EMAIL` | the first person's address |
|
||||
| `SEED_ADMIN_NAME` | their name, as it should appear (optional) |
|
||||
| `SEED_GROUP` | a group to put them in, if not the most privileged one (optional) |
|
||||
| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `redaktor` (optional) |
|
||||
|
||||
## 4. Start portal
|
||||
|
||||
At start, portal:
|
||||
|
||||
1. **Invites the first person** into `SEED_GROUP`, or the most
|
||||
privileged group, and mails them the one-time link. Once: it is
|
||||
remembered in the `portal_seed` bucket, so restarts and upgrades
|
||||
never send a second link.
|
||||
2. **Makes an account for everyone named responsible** who has none,
|
||||
and mails them that they are listed as responsible for asking those
|
||||
questions, with their link. With `RESPONSIBLE_GROUP` they all join
|
||||
it; someone who already had an account is added without a mail.
|
||||
|
||||
The two run side by side. Kanidm may still be starting beside portal,
|
||||
so each is tried again a minute apart, up to twenty times. The log says
|
||||
what happened, per person.
|
||||
|
||||
The second happens again **whenever the content changes**, so a new
|
||||
page naming a new person sets them up without a restart. And it works
|
||||
both ways: someone the content has stopped naming leaves
|
||||
`RESPONSIBLE_GROUP` in the same pass, and with it whatever else that
|
||||
group opens. Only if portal is who put them there - a person who was in
|
||||
the group before is left in it - and their account stays, since it is
|
||||
theirs.
|
||||
|
||||
## 5. The rest come in from the desks
|
||||
|
||||
From here nobody touches Kanidm:
|
||||
|
||||
- **At a desk**, whoever may invite fills in a name and an address.
|
||||
Portal makes the account, adds them to the group, and mails the link.
|
||||
- **A decision can be the invitation.** A state with `grants: group`
|
||||
in `aggregates.yaml` puts the person a case is about into that group
|
||||
when the case arrives there - an approved committee application that
|
||||
*is* the membership.
|
||||
- **A new page with a new responsible person** gets them an account
|
||||
when the page goes live.
|
||||
|
||||
### An invite that nobody used
|
||||
|
||||
A link lives a day. Every invite - a desk's, and the ones portal made
|
||||
at start - is a case in `portal_invites`, and keeps itself current:
|
||||
|
||||
| state | means | how it gets there |
|
||||
|---|---|---|
|
||||
| `open` | the link is waiting | the invite was sent, or sent again |
|
||||
| `done` | the person is in | they signed in to the site; or a desk says so |
|
||||
| `expired` | the link ran out unused | a sweep, every ten minutes |
|
||||
|
||||
A desk that lists the bucket offers the way back with a transition from
|
||||
`expired` to `open`. Pressing it makes a fresh link and mails it:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_invites }
|
||||
requires_group: styret
|
||||
transitions:
|
||||
- { from: open, to: done, label: Is in }
|
||||
- { from: expired, to: open, label: Send again }
|
||||
- { from: expired, to: done, label: Is in }
|
||||
```
|
||||
|
||||
## 6. What the people who ask the questions can do
|
||||
|
||||
Signed in, a person a page names as responsible sees a card at the
|
||||
foot of their own pages to suggest a change. The server checks it is
|
||||
really them, and the suggestion is mailed to the site's inbox with them
|
||||
as reply-to, so answering it answers them.
|
||||
|
||||
It is also a case, in the built-in `portal_edit_suggestions` bucket, so
|
||||
it does not depend on somebody reading that inbox. Give a desk the
|
||||
list, and whoever sits there takes it up and settles it:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_edit_suggestions }
|
||||
requires_group: styret
|
||||
transitions:
|
||||
- { from: open, to: discussed, label: Looking at it }
|
||||
- { from: open, to: done, label: Changed }
|
||||
- { from: open, to: declined, label: Will not change }
|
||||
- { from: discussed, to: done, label: Changed }
|
||||
- { from: discussed, to: declined, label: Will not change }
|
||||
```
|
||||
|
||||
The person who suggested it is mailed at each move, in the site's
|
||||
language, and the card on their page lists what they have sent and
|
||||
where each one stands. A content repo that declares the bucket itself
|
||||
in `aggregates.yaml`, with a `mail:` on a state, sends that instead.
|
||||
|
||||
Their group can also be the way into other tools. The vel's
|
||||
`redaktor` group signs in to the project Gitea with the same account:
|
||||
a second OAuth2 client scoped to that group alone.
|
||||
|
||||
## When it does not work
|
||||
|
||||
| what you see | why |
|
||||
|---|---|
|
||||
| an invite fails with `403` setting `mail` | a portal before 0.5.4 set the address in a second call; the onboarding account may create but not modify |
|
||||
| an invite fails with `404` adding to a group | the group is not managed by `portal-onboarding`: `group set-entry-manager <group> portal-onboarding` |
|
||||
| `Item not found` setting the login page's name or logo | those are system settings only `admin` may change, not `idm_admin` - or the CLI and server versions differ |
|
||||
| accounts are made but nobody is mailed | `site.yaml` has no `mail.from`, or gdo is not running on the host |
|
||||
| someone responsible never got a mail | they already had an account (they are only added to `RESPONSIBLE_GROUP`), or their `contact` is not an email address |
|
||||
| an invite stays `open` though the person has set a passkey | Kanidm does not show a passkey to the onboarding account; it becomes `done` when they sign in to the site |
|
||||
| "send again" is not on the desk | the list has no `{ from: expired, to: open }` transition, or the content declares `portal_invites` itself without an `expired` state |
|
||||
| someone left a page and is still in `RESPONSIBLE_GROUP` | they were in the group before portal added them, so it is not portal's to undo |
|
||||
| the first person was invited twice | it cannot be, per address and group; changing `SEED_ADMIN_EMAIL` or `SEED_GROUP` invites the new pair |
|
||||
@@ -0,0 +1,108 @@
|
||||
# Memberships
|
||||
|
||||
Who belongs to which group, kept by portal itself.
|
||||
|
||||
A site's groups are named in its content: `qualifies` on a page,
|
||||
`requires_group` on a list, `invite: { group }` on a desk, `grants` on
|
||||
a state. Until now the groups themselves lived in Kanidm, and portal
|
||||
read them from the sign-in token. With memberships they live in portal,
|
||||
as records in the built-in `portal_memberships` bucket, and the
|
||||
identity provider only has to say who someone is. Cedar sees no
|
||||
difference: a person's active memberships are the parents of their
|
||||
`User` entity, exactly as a `groups` claim was.
|
||||
|
||||
## A membership is a case
|
||||
|
||||
One record per group and person:
|
||||
|
||||
| state | means | how it gets there |
|
||||
|---|---|---|
|
||||
| `invited` | a place is held for them | a desk invite, a state that `grants`, the first person, someone the content names as responsible |
|
||||
| `active` | they are in | they signed in, and the provider's subject, phone number or address matched the record |
|
||||
| `removed` | a desk took them out | a button on the desk that lists the bucket; inviting them again reopens the record |
|
||||
|
||||
The record carries `name`, `group`, `by` (who or what put them there:
|
||||
a username, `seed`, `responsible`, or the case as `bucket:id`), and
|
||||
what is known of the person: `phone`, `email`, `sub`. At sign-in
|
||||
everything the provider verified is written onto every record that
|
||||
matched, so a record made from an address learns the phone number,
|
||||
and the next sign-in matches on the subject before anything else.
|
||||
|
||||
Because it is a case, a desk lists it like any other bucket:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_memberships }
|
||||
requires_group: styret
|
||||
transitions:
|
||||
- { from: invited, to: removed, label: Withdraw }
|
||||
- { from: active, to: removed, label: Take out }
|
||||
- { from: removed, to: invited, label: Invite again }
|
||||
```
|
||||
|
||||
## A person is their phone number first
|
||||
|
||||
A provider that verifies phone numbers (Vipps) makes the number the
|
||||
surest thing a record can hold. So a person is known by, in order:
|
||||
their phone number, their email address, the provider's subject. A
|
||||
form that collects `phone`, `telefon`, `tel` or `mobil` gives the
|
||||
first; `email` or `epost` the second. Numbers are kept as `+4791234567`
|
||||
whatever way they were typed; a bare national number gets the site's
|
||||
country (`PHONE_COUNTRY`, `+47` unless set). Addresses are lowercased.
|
||||
|
||||
A record matches a person on any one of the three, so a desk that
|
||||
invited by address and a decision that granted by number are talking
|
||||
about one person, and there is one record.
|
||||
|
||||
## Switching a site to memberships
|
||||
|
||||
```
|
||||
PEOPLE_BACKEND=memberships
|
||||
```
|
||||
|
||||
Unset, a `KANIDM_API_TOKEN` means Kanidm and no token means
|
||||
memberships. With memberships:
|
||||
|
||||
- A desk invite writes an `invited` record and mails "log in at the
|
||||
site, and your place is there". The provider makes people, portal
|
||||
makes members: with Vipps Login everyone can already sign in. A
|
||||
Kanidm still configured beside memberships (`KANIDM_API_TOKEN`)
|
||||
makes the account too, with no group and the one-time link in the
|
||||
mail, so a site can switch to memberships before its provider does.
|
||||
- `grants` writes a record for the person the case is about, from the
|
||||
form's name and phone number or address.
|
||||
- `SEED_ADMIN_PHONE` or `SEED_ADMIN_EMAIL` writes the first person's.
|
||||
- The people the content names as responsible get theirs in
|
||||
`RESPONSIBLE_GROUP`, and lose it when the content stops naming them.
|
||||
- The `groups` claim, if the provider sends one, still counts: a
|
||||
person's groups are the union.
|
||||
|
||||
With `OIDC_EXTRA_SCOPES=phoneNumber` the sign-in asks the provider for
|
||||
the number and reads `phone_number` from userinfo. Set it for Vipps;
|
||||
not for Kanidm, which denies a sign-in that asks for a scope the
|
||||
person does not hold. Without it, matching falls back to the address.
|
||||
|
||||
## Asking who belongs
|
||||
|
||||
Other applications ask portal the same access question they always
|
||||
could, over NATS on `portal.access.<site>.may`. A principal that names
|
||||
a person and lists no groups is looked up:
|
||||
|
||||
```json
|
||||
{ "principal": { "kind": "user", "sub": "vipps:abc", "phone": "912 34 567" },
|
||||
"action": "transition",
|
||||
"resource": { "kind": "bucket", "name": "hazards" },
|
||||
"context": { "from": "open", "to": "fixed" } }
|
||||
```
|
||||
|
||||
The answer is Cedar's, with the rule ids that granted it, for a person
|
||||
who may never have opened the site. That is the whole authorization
|
||||
engine: the provider says who, memberships say what they belong to,
|
||||
the policy compiled from content says what they may do.
|
||||
|
||||
## What Kanidm still does
|
||||
|
||||
For a site on Kanidm nothing changes: invites make accounts and add to
|
||||
Kanidm groups, and `portal_invites` tracks the one-time links. The two
|
||||
backends can be swapped by the one variable; the content and the
|
||||
policy are the same either way.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Operations
|
||||
|
||||
Portal does not deploy itself. It publishes versions; each site
|
||||
decides when to take one.
|
||||
|
||||
## Cut a release
|
||||
|
||||
```sh
|
||||
cargo release patch # or minor / major
|
||||
```
|
||||
|
||||
That bumps `Cargo.toml`, tags `v<version>` and pushes. CI
|
||||
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
|
||||
attaches `portal-v<version>.tar.gz` (`portal`, `iris`, `hash.txt`,
|
||||
`site/`) to the Gitea release. Plain pushes to `main` only run the
|
||||
tests: nothing reaches production from this repo.
|
||||
|
||||
The same job re-pins, tests and tags [iris](https://project.uhhm.no/uhhm/iris)
|
||||
to match, so a portal change to anything the lint reads fails the
|
||||
release rather than the next content push.
|
||||
|
||||
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
|
||||
heading for the new version. That section becomes the release notes,
|
||||
and the table at the end of the file records which version each site
|
||||
runs.
|
||||
|
||||
Pkg files are content-hashed (`hash-files = true`, instances run with
|
||||
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
|
||||
stale cached bundle with new wasm.
|
||||
|
||||
## Roll it out
|
||||
|
||||
In the content repo, one line of `.gitea/workflows/deploy.yml`:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
PORTAL_RELEASE: v0.4.0
|
||||
```
|
||||
|
||||
Its CI downloads the pinned artifact, ships
|
||||
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
|
||||
instance env from that repo's own Actions variables and secrets,
|
||||
restarts `app@<instance>` and refreshes the Caddy route. Every rollout
|
||||
is a commit, so reverting a bad version is `git revert` and a push.
|
||||
|
||||
Sites upgrade independently and may pin different versions.
|
||||
|
||||
Content changes never come through any of this: they hot-reload live
|
||||
from the content repos over NATS.
|
||||
|
||||
## Add a site
|
||||
|
||||
A new content repo with a copy of an existing `deploy.yml` (change
|
||||
`INSTANCE`, the port and the domains), Actions variables
|
||||
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
|
||||
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`, `PORTAL_GITEA_API_TOKEN`
|
||||
— Gitea reserves the `GITEA_` prefix for secret names), a Kanidm
|
||||
OAuth2 client, and DNS. `site.yaml` handles all branding; no portal
|
||||
change is needed.
|
||||
|
||||
## Environment
|
||||
|
||||
Runtime configuration is environment variables; `src/main.rs` is the
|
||||
authority and each content repo's `deploy.yml` holds the production
|
||||
values.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `NATS_URL` | events, projections, sessions, content reload, access questions |
|
||||
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
|
||||
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
||||
| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise |
|
||||
| `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm |
|
||||
| `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set |
|
||||
| `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
|
||||
| `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
|
||||
| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way |
|
||||
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
|
||||
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
||||
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
|
||||
| `GARAGE_*` | uploads |
|
||||
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
|
||||
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
|
||||
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
|
||||
|
||||
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
|
||||
separate unit on the same host reading the same NATS.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Every request passes a per-caller-IP limit before a session is loaded
|
||||
or a handler is entered: a burst of 30, refilled one every two
|
||||
seconds. A visitor filling in a form never reaches it (a page load
|
||||
fetches several resources at once); a script does.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
|
||||
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
|
||||
|
||||
Either set to `0` turns it off, for an instance behind something that
|
||||
already does this. The caller is read from `X-Forwarded-For` or
|
||||
`X-Real-IP`, falling back to the socket — all of these instances are
|
||||
behind Caddy.
|
||||
|
||||
This is a fence, not an accounting system. What keeps uploads honest
|
||||
is the policy check on the route itself.
|
||||
|
||||
## Attachments
|
||||
|
||||
A `type: file` field stores an object key in the record. The file
|
||||
comes back out at:
|
||||
|
||||
GET /attachment/{bucket}/{record}/{field}
|
||||
|
||||
which reads the key out of that record rather than taking one from the
|
||||
caller, so a file can only be fetched through a record somebody is
|
||||
allowed to see, and a leaked key is a link to nothing. Reading it
|
||||
needs the same policy permission as reading the bucket on a desk.
|
||||
|
||||
Uploads need a bucket and a key in the Garage on the host:
|
||||
|
||||
```sh
|
||||
garage bucket create <site>-attachments
|
||||
garage key create <site>-portal-uploads
|
||||
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads
|
||||
```
|
||||
|
||||
then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
|
||||
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
||||
fails closed rather than appearing to work and storing nowhere.
|
||||
|
||||
## The report
|
||||
|
||||
`GET /report` is public and answers with counts only: per bucket, per
|
||||
[storyline](storylines.md), per state. It is what the trainer reads to
|
||||
compare the storylines on what people did.
|
||||
|
||||
## The docs site
|
||||
|
||||
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
|
||||
with mdBook (`book.toml`, `docs/SUMMARY.md`) by `.gitea/workflows/docs.yml`
|
||||
on every push to main that touches them, and put to git-pages the way
|
||||
every static site on uhhm.no is (uhhm/infrastructure `PAGES.md`). The
|
||||
changelog is the book's last page. `mdbook serve` previews it locally.
|
||||
|
||||
## Who deploys what
|
||||
|
||||
Both hosts deploy from CI now, and a rollout is the same commit either
|
||||
way: bump `PORTAL_RELEASE` in the site's own content repo.
|
||||
|
||||
| | runner | how it is allowed to |
|
||||
|---|---|---|
|
||||
| ergo | `runs-on: bare` | the runner is privileged and writes `/srv/app` and `/etc/app` itself |
|
||||
| kasse | `runs-on: fish` | the runner is not privileged. It may run one root script, `/usr/local/bin/deploy-portal-instance`, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything |
|
||||
|
||||
The kasse grant is a single sudoers line
|
||||
(`/etc/sudoers.d/30-portal-deploy`). It exists so a workflow can pick
|
||||
which of three known instances gets which published portal tag, and
|
||||
nothing else.
|
||||
|
||||
Both paths health-check the instance afterwards **without** a forwarded
|
||||
header. A check that sends the header Caddy would send cannot tell you
|
||||
the site is broken for everything that is not Caddy, which is how
|
||||
v0.5.1 reached a host.
|
||||
|
||||
`IRIS_RELEASE` in a repo's lint workflow should equal its
|
||||
`PORTAL_RELEASE`. Bump it only once iris's own publish job has attached
|
||||
the binary for that tag - portal's release tags iris, but iris builds
|
||||
and uploads on its own run, and a pin bumped in between fails the lint
|
||||
on a download that is not there yet.
|
||||
@@ -0,0 +1,161 @@
|
||||
# State machines
|
||||
|
||||
`aggregates.yaml` declares what a bucket's records may be and how they
|
||||
move. It is the one file that decides what is possible; the pages only
|
||||
decide who presses what.
|
||||
|
||||
```yaml
|
||||
aggregates:
|
||||
- bucket: applications
|
||||
initial: open
|
||||
states:
|
||||
open: { event: received }
|
||||
incomplete: { event: incomplete }
|
||||
complete: { event: complete }
|
||||
on_hearing: { event: on_hearing }
|
||||
recommended: { event: recommended }
|
||||
approved: { event: approved }
|
||||
refused: { event: refused }
|
||||
withdrawn: { event: withdrawn }
|
||||
transitions:
|
||||
open: [incomplete, complete, withdrawn]
|
||||
incomplete: [complete, withdrawn]
|
||||
complete: [incomplete, on_hearing, refused, withdrawn]
|
||||
on_hearing: [incomplete, recommended, refused, withdrawn]
|
||||
recommended: [approved, refused, withdrawn]
|
||||
```
|
||||
|
||||
`transitions` is an arbitrary directed graph. Cycles are allowed and
|
||||
are often the honest shape: a case sent back for the drawing it was
|
||||
missing returns to a stage it was already past. A good ending may sit
|
||||
partway along — an authority that changes its own decision finishes
|
||||
the case without walking to the end of the line.
|
||||
|
||||
Each state's `event` string is the name written into the log, and must
|
||||
be unique within the bucket. A record's state is replayed from its own
|
||||
events, so renaming an event orphans the records that carry the old
|
||||
name: when rebuilding a live site, keep the names it already has.
|
||||
|
||||
## What a state may carry
|
||||
|
||||
Beside its `event`, a state may declare three things that happen when
|
||||
a record arrives in it.
|
||||
|
||||
### mail
|
||||
|
||||
```yaml
|
||||
approved:
|
||||
event: approved
|
||||
mail:
|
||||
to: submitter # or one fixed address
|
||||
subject: "Your application"
|
||||
body: |
|
||||
Hello,
|
||||
|
||||
{property_address} is approved. The decision is at
|
||||
{site}/status/{chain}.
|
||||
```
|
||||
|
||||
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
||||
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
|
||||
as text with an HTML alternative. `to: submitter` means the record's
|
||||
`email` field, and the lint then requires every form filling that
|
||||
bucket to collect a required `email`.
|
||||
|
||||
A record that belongs to another (a `type: record` field, say
|
||||
`notice`) can write to that record's people: `to: "{notice.email}"`
|
||||
goes to whoever posted the notice, and `{notice.hva}` in the body is
|
||||
what they posted. The lint requires every form filling the bucket to
|
||||
carry that field. This is how an answer to a notice on a board reaches
|
||||
the neighbour who put it there, without anyone in between.
|
||||
|
||||
Portal renders it and publishes on `portal.mail.send`. gdo delivers
|
||||
it, and a reply comes back on `portal.mail.received` as a note beside
|
||||
the record. A note never moves a case.
|
||||
|
||||
### grants
|
||||
|
||||
```yaml
|
||||
approved:
|
||||
event: approved
|
||||
grants: approved_firms
|
||||
```
|
||||
|
||||
The person the record is about becomes a member of that group when the
|
||||
record arrives here. An approval that *means* membership should not
|
||||
also need somebody to remember to invite them afterwards.
|
||||
|
||||
The lint requires every form filling that bucket to collect a required
|
||||
`name` and a required `email`, because by the time a desk presses
|
||||
approve there is nobody left to ask. With memberships as the people
|
||||
backend a phone number does as well as an address, and the grant is a
|
||||
record in `portal_memberships` rather than a Kanidm call (see
|
||||
[Memberships](memberships.md)). An instance with neither configured
|
||||
grants nothing and says so in the log; the transition itself always
|
||||
stands.
|
||||
|
||||
### after
|
||||
|
||||
```yaml
|
||||
on_hearing:
|
||||
event: on_hearing
|
||||
after: { days: 42, to: recommended }
|
||||
```
|
||||
|
||||
The move a clock makes rather than a person. A hearing that gets no
|
||||
answer in six weeks has to go on without one, and nobody in the
|
||||
building is in a position to press that button.
|
||||
|
||||
A deadline may only do what a person could have done: `after.to` is
|
||||
held to the same declared edge as a desk button, and content that says
|
||||
otherwise is refused when it loads, not when the hour comes round. The
|
||||
wait is measured from the record's last move. The move goes through
|
||||
the same transition path, fires the same mail, and lands on
|
||||
`portal.answers.submitted` like any decision, stamped `deadline`.
|
||||
|
||||
The unit is days and the sweep is hourly. This is a deadline, not a
|
||||
job queue.
|
||||
|
||||
## attended_by
|
||||
|
||||
```yaml
|
||||
- bucket: newsletter
|
||||
initial: open
|
||||
states: { open: { event: received } }
|
||||
attended_by: n8n newsletter compose
|
||||
```
|
||||
|
||||
Names what consumes a bucket no page reads back. The lint refuses a
|
||||
bucket that a form fills and nothing attends — a question was once
|
||||
dropped and its answers silently fell out of every reader's view, and
|
||||
this is the contract that replaced the convention.
|
||||
|
||||
## Who moves what
|
||||
|
||||
Nothing in this file says who. A move becomes possible for somebody
|
||||
when a page offers it:
|
||||
|
||||
- a desk resource's `transitions` gives it to that resource's
|
||||
`requires_group`;
|
||||
- an alternative's `self_transition` gives it to the person who sent
|
||||
the record, by their link and their email, from the states its
|
||||
`from` names;
|
||||
- a state's `after` gives it to the clock.
|
||||
|
||||
All three compile into the same [access policy](access.md), and the
|
||||
engine refuses anything the graph above does not declare, whoever asks.
|
||||
|
||||
## Testing one
|
||||
|
||||
`iris replay` runs cases through this exact machinery — the same
|
||||
`store_answer` and `transition` the running site uses, against a
|
||||
throwaway JetStream, reporting what the engine accepted and refused.
|
||||
|
||||
```sh
|
||||
docker run -d --rm -p 127.0.0.1:14222:4222 nats:2.14.6-alpine -js
|
||||
iris replay --path questions --nats nats://127.0.0.1:14222 --cases cases.jsonl
|
||||
```
|
||||
|
||||
Each line of `cases.jsonl` is one case: a need, a record, and the
|
||||
states it walks. Point it at a throwaway NATS, never a site's own: it
|
||||
writes.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Storylines
|
||||
|
||||
The same site, told two ways, and counted.
|
||||
|
||||
Anywhere content says something to a person, it may say it in several
|
||||
ways by giving a list instead of one string:
|
||||
|
||||
```yaml
|
||||
name: Vil du hjelpe til i nabolaget?
|
||||
description:
|
||||
- Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
|
||||
- Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
|
||||
alternatives:
|
||||
- name: [Bli med, Meld deg]
|
||||
consequence: [[Send], [Meld meg]]
|
||||
```
|
||||
|
||||
Each index is a *storyline*. A visitor is given one at their first
|
||||
request, kept in a cookie (`portal_storyline`) for a year, and reads
|
||||
that storyline on every page, in every button and in every mail the
|
||||
site sends them about their case. A single string is every storyline.
|
||||
|
||||
## The rules
|
||||
|
||||
- **Same length everywhere.** Every list of texts in the content has
|
||||
the site's arity, so a storyline never has a hole in it. The lint
|
||||
refuses a list of another length and names it.
|
||||
- **What varies is what a person reads:** `name`, `description`,
|
||||
`label`, `placeholder`, `title`, a mail's `subject` and `body`, a
|
||||
list's `empty` line, `consequence` and `encouragements` (those two as
|
||||
lists of lists).
|
||||
- **What the machine reads never varies:** a field's `name` (the stored
|
||||
key), a select's `options` (the stored values), states, groups,
|
||||
paths, buckets.
|
||||
- **One storyline per site**, not per page. That is what makes it a
|
||||
storyline rather than a scatter of variants: a warm front door leads
|
||||
to a warm form and a warm mail.
|
||||
- A signed-in person keeps the storyline they had as a visitor.
|
||||
- A site that tells one storyline sets no cookie.
|
||||
|
||||
## What is counted
|
||||
|
||||
Every record and every answer event carries the storyline it came
|
||||
from (`storyline` on `Answer` and on `portal.answers.submitted`). The
|
||||
mail a case gets tells the same storyline, so a person who read the
|
||||
warm front door is not thanked in the plain voice.
|
||||
|
||||
`GET /report` gives the counts, per bucket, per storyline, per state:
|
||||
|
||||
```json
|
||||
{ "storylines": 2,
|
||||
"buckets": { "hjelpebehov": { "0": { "open": 3, "ordnet": 9 },
|
||||
"1": { "open": 5, "ordnet": 14 } } } }
|
||||
```
|
||||
|
||||
Storylines are assigned evenly, so those numbers are the comparison:
|
||||
which words brought more people to send, and how their cases went. No
|
||||
record, field, address or time leaves the site. The trainer reads this
|
||||
(`portal-trainer report`) next to what its personas scored offline, and
|
||||
the plan's `tones` are where the storylines are written.
|
||||
@@ -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
+118
-16
@@ -51,7 +51,17 @@ action invite appliesTo { principal: [User], resource: [Group] };
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Principal {
|
||||
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
|
||||
User { sub: String, groups: Vec<String> },
|
||||
/// Over NATS, a question may name the person by phone number or
|
||||
/// address instead of listing their groups, and portal looks their
|
||||
/// memberships up (see `serve`).
|
||||
User {
|
||||
sub: String,
|
||||
groups: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
phone: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
},
|
||||
/// Whoever holds one record's own link and knows the email on it.
|
||||
Submitter { record: String, email: String },
|
||||
/// A machine with a shared token.
|
||||
@@ -60,12 +70,12 @@ pub enum Principal {
|
||||
|
||||
impl Principal {
|
||||
pub fn anonymous() -> Self {
|
||||
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
|
||||
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
|
||||
}
|
||||
|
||||
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
||||
match user {
|
||||
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() },
|
||||
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
|
||||
None => Principal::anonymous(),
|
||||
}
|
||||
}
|
||||
@@ -221,16 +231,27 @@ impl Policy {
|
||||
}
|
||||
}
|
||||
if let Some(st) = &alt.self_transition {
|
||||
let from = crate::answers::OPEN_STATE;
|
||||
emit(
|
||||
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
||||
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
|
||||
format!(
|
||||
"permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};",
|
||||
quote(&st.bucket), quote(from), quote(&st.to)
|
||||
),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
// One rule per state the move may be made from: a
|
||||
// person stays in dialogue with their case for as
|
||||
// long as it runs, and `from` says how long that
|
||||
// is. Saying nothing means the bucket's own
|
||||
// initial state - the bucket's, not the word
|
||||
// `open`, since a bucket may start anywhere.
|
||||
let initial = aggregates
|
||||
.get(&st.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
|
||||
for from in &st.from_states(&initial) {
|
||||
emit(
|
||||
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
||||
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
|
||||
format!(
|
||||
"permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};",
|
||||
quote(&st.bucket), quote(from), quote(&st.to)
|
||||
),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let (Some(invite), Some(g)) = (&alt.invite, &q.qualifies) {
|
||||
emit(
|
||||
@@ -300,7 +321,7 @@ impl Policy {
|
||||
};
|
||||
let mut entities: Vec<Entity> = Vec::new();
|
||||
let principal_uid = match principal {
|
||||
Principal::User { sub, groups } => {
|
||||
Principal::User { sub, groups, .. } => {
|
||||
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
|
||||
for g in &parents {
|
||||
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
|
||||
@@ -433,6 +454,7 @@ pub async fn serve(
|
||||
nats: async_nats::Client,
|
||||
site: String,
|
||||
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
|
||||
state: Option<crate::server::AppState>,
|
||||
) {
|
||||
use futures::StreamExt;
|
||||
let subject = subject_for(&site);
|
||||
@@ -447,7 +469,22 @@ pub async fn serve(
|
||||
while let Some(msg) = sub.next().await {
|
||||
let Some(reply) = msg.reply else { continue };
|
||||
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
|
||||
Ok(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context),
|
||||
Ok(mut q) => {
|
||||
// A question that names a person and lists no groups is
|
||||
// asking portal what they belong to: their memberships,
|
||||
// matched on subject, phone number or address.
|
||||
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
|
||||
if groups.is_empty() && sub != "anonymous" {
|
||||
let who = crate::memberships::Identity {
|
||||
sub: Some(sub.clone()),
|
||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||
email: email.as_deref().map(|e| e.trim().to_lowercase()),
|
||||
};
|
||||
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
|
||||
}
|
||||
}
|
||||
decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context)
|
||||
}
|
||||
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
|
||||
};
|
||||
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||||
@@ -476,7 +513,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn user(groups: &[&str]) -> Principal {
|
||||
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
|
||||
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -536,6 +573,71 @@ mod tests {
|
||||
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
||||
}
|
||||
|
||||
// The applicant written to for a missing drawing sends it from
|
||||
// `incomplete`, and may withdraw at any point up to the signature.
|
||||
// Before `from`, every one of those moves was compiled as if it
|
||||
// could only be made on the day the case arrived.
|
||||
#[test]
|
||||
fn a_submitter_stays_in_dialogue_for_as_long_as_the_case_runs() {
|
||||
let files = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: Apply\n record_as: cases\n - name: Send the papers\n self_transition: { bucket: cases, to: complete, from: [open, incomplete], label: Send }\n - name: Withdraw\n self_transition: { bucket: cases, to: withdrawn, from: [open, incomplete, complete], label: Withdraw }\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
let aggregates = parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: cases\n initial: open\n states:\n open: { event: a }\n incomplete: { event: b }\n complete: { event: c }\n withdrawn: { event: d }\n transitions:\n open: [incomplete, complete, withdrawn]\n incomplete: [complete, withdrawn]\n complete: [withdrawn]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let p = Policy::from_content(&questions, &aggregates);
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
let bucket = Resource::Bucket { name: "cases".into() };
|
||||
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
||||
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" });
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow);
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow);
|
||||
// And only where it was said: the paperwork cannot be sent
|
||||
// after the case is already withdrawn.
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow);
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow);
|
||||
}
|
||||
|
||||
// Omitted, `from` is the initial state alone - what every
|
||||
// unsubscribe link written before this existed already meant.
|
||||
#[test]
|
||||
fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() {
|
||||
let p = site();
|
||||
assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed"));
|
||||
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
|
||||
}
|
||||
|
||||
// "On arrival" is the bucket's own first state, not the word
|
||||
// `open`. redoal's watchers arrive at `pending` and confirm
|
||||
// themselves into `open`; reading the literal made that rule
|
||||
// `open -> open`, which the engine declares nowhere, so the
|
||||
// confirm link in the mail could never have worked.
|
||||
#[test]
|
||||
fn arrival_is_wherever_the_bucket_actually_starts() {
|
||||
let files = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
let aggregates = parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let p = Policy::from_content(&questions, &aggregates);
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
assert!(
|
||||
p.rules.iter().any(|r| r.id == "self:watchers:pending>open"),
|
||||
"the rule must start where the records do: {:?}",
|
||||
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
||||
);
|
||||
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
|
||||
let bucket = Resource::Bucket { name: "watchers".into() };
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invites_and_automation() {
|
||||
let p = site();
|
||||
|
||||
@@ -36,6 +36,59 @@ 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>,
|
||||
/// What a state grants the person whose record entered it: a group
|
||||
/// they become a member of. A firm approved to file is a member of
|
||||
/// the approved firms from that moment, and the decision that made
|
||||
/// it so is the same one the desk already pressed - nobody should
|
||||
/// have to remember to invite them afterwards.
|
||||
pub grants_for_state: HashMap<String, String>,
|
||||
/// The move a clock makes rather than a person. A hearing that gets
|
||||
/// no answer in six weeks has to go on without one, and nothing in
|
||||
/// a building is going to press that button.
|
||||
pub after_for_state: HashMap<String, AfterSpec>,
|
||||
}
|
||||
|
||||
/// 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), one fixed address, or a field of the record
|
||||
/// this one belongs to: `{notice.email}` when the form has a
|
||||
/// `type: record` field called `notice`. That is how an answer to a
|
||||
/// notice reaches whoever posted it. Subject and body are templates:
|
||||
/// `{field}` for any field of the record, `{notice.field}` for one of
|
||||
/// the record it belongs to, 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"
|
||||
}
|
||||
}
|
||||
|
||||
/// A move made by a clock: `after: { days: 42, to: no_answer }` on the
|
||||
/// state a record is waiting in. The wait is measured from when the
|
||||
/// record entered that state, and `to` must be an edge the same schema
|
||||
/// already declares - a deadline may only do what a person could have
|
||||
/// done. Days, because every deadline a public body keeps is in days
|
||||
/// and a shorter unit would invite this to be used as a job queue.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AfterSpec {
|
||||
pub days: u32,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl AggregateSchema {
|
||||
@@ -43,6 +96,18 @@ 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 grants_for(&self, state: &str) -> Option<&str> {
|
||||
self.grants_for_state.get(state).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn after_for(&self, state: &str) -> Option<&AfterSpec> {
|
||||
self.after_for_state.get(state)
|
||||
}
|
||||
|
||||
pub fn allowed(&self, state: &str) -> &[String] {
|
||||
self.transitions
|
||||
.get(state)
|
||||
@@ -54,6 +119,12 @@ impl AggregateSchema {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawState {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
mail: Option<MailSpec>,
|
||||
#[serde(default)]
|
||||
grants: Option<String>,
|
||||
#[serde(default)]
|
||||
after: Option<AfterSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -123,6 +194,45 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
}
|
||||
}
|
||||
|
||||
let mut mail_for_state = HashMap::new();
|
||||
let mut grants_for_state = HashMap::new();
|
||||
let mut after_for_state = HashMap::new();
|
||||
for (state, raw_state) in raw_schema.states {
|
||||
if let Some(group) = raw_state.grants {
|
||||
if group.trim().is_empty() {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
|
||||
}
|
||||
grants_for_state.insert(state.clone(), group);
|
||||
}
|
||||
if let Some(after) = raw_state.after {
|
||||
if after.days == 0 {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: after.days must be at least one");
|
||||
}
|
||||
// A clock may only make a move a person could have
|
||||
// made, so it is held to the same graph.
|
||||
if !raw_schema.transitions.get(&state).is_some_and(|tos| tos.contains(&after.to)) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it",
|
||||
after.to
|
||||
);
|
||||
}
|
||||
after_for_state.insert(state.clone(), after);
|
||||
}
|
||||
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");
|
||||
}
|
||||
let through_a_record = crate::mail::parent_of(&mail.to).is_some();
|
||||
if !mail.to_submitter() && !through_a_record && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}",
|
||||
mail.to
|
||||
);
|
||||
}
|
||||
mail_for_state.insert(state, mail);
|
||||
}
|
||||
}
|
||||
|
||||
out.insert(
|
||||
bucket.clone(),
|
||||
AggregateSchema {
|
||||
@@ -132,6 +242,9 @@ 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,
|
||||
grants_for_state,
|
||||
after_for_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -349,6 +462,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(),
|
||||
|
||||
@@ -84,6 +84,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
EVENT_ALTERNATIVE,
|
||||
&responses,
|
||||
now_ms,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(question = %q.id, "event record created");
|
||||
@@ -137,6 +138,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
alternative: SUMMARY_DUE_LABEL.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: 0,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
+57
-2
@@ -24,10 +24,34 @@ pub struct Answer {
|
||||
pub responses: serde_json::Value,
|
||||
pub state: String,
|
||||
pub submitted_ms: i64,
|
||||
/// The storyline the record was sent from, so the mail it gets
|
||||
/// tells the same one (see `content/variants.rs`).
|
||||
#[serde(default)]
|
||||
pub storyline: u32,
|
||||
#[serde(default)]
|
||||
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 -
|
||||
@@ -44,6 +68,7 @@ pub async fn store_answer(
|
||||
alternative: &str,
|
||||
responses: &serde_json::Value,
|
||||
submitted_ms: i64,
|
||||
storyline: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
let store = match js.get_key_value(bucket).await {
|
||||
Ok(store) => store,
|
||||
@@ -69,7 +94,9 @@ pub async fn store_answer(
|
||||
responses: responses.clone(),
|
||||
state: initial_state,
|
||||
submitted_ms,
|
||||
storyline,
|
||||
decided_ms: None,
|
||||
notes: Vec::new(),
|
||||
decided_by: None,
|
||||
};
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
@@ -111,9 +138,11 @@ pub async fn transition_answers(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let storyline = crate::server::storyline::current().await;
|
||||
let alt = state
|
||||
.questions
|
||||
.load()
|
||||
.at(storyline)
|
||||
.get(&question_id)
|
||||
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
||||
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
||||
@@ -146,7 +175,7 @@ pub async fn transition_answers(
|
||||
/// transition retries. Keeps "zap the whole event stream" a safe
|
||||
/// operation instead of one that strands every pre-wipe record.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn transition_or_reseed(
|
||||
pub(crate) async fn transition_or_reseed(
|
||||
js: &async_nats::jetstream::Context,
|
||||
schema: &crate::aggregates::AggregateSchema,
|
||||
item_id: &str,
|
||||
@@ -268,6 +297,12 @@ 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, answer.storyline).await;
|
||||
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||
// An expired invite sent again: the move back is the asking.
|
||||
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
||||
crate::people::on_reopened(state, &item.item_id).await;
|
||||
}
|
||||
|
||||
// The decision extends the DAG as a child of the answer's own
|
||||
// submission hash, published like any other answer.
|
||||
@@ -280,6 +315,7 @@ async fn apply_transition(
|
||||
alternative: transition.label,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
.await
|
||||
@@ -348,7 +384,19 @@ pub async fn self_transition_answer(
|
||||
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
||||
);
|
||||
if !decision.allow {
|
||||
return Err(ServerFnError::new(if answer.state == OPEN_STATE { "not found" } else { "already processed" }));
|
||||
// Told apart by which half of the rule failed. If the record is
|
||||
// somewhere this button may be fired from, the email was wrong,
|
||||
// and the answer must not confirm that the record exists. If it
|
||||
// is not, the case has moved past this move and saying so is no
|
||||
// longer a way to learn anything about it.
|
||||
let initial = state
|
||||
.aggregates
|
||||
.load()
|
||||
.get(&self_transition.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| OPEN_STATE.to_string());
|
||||
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
||||
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
@@ -378,6 +426,8 @@ 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, answer.storyline).await;
|
||||
crate::people::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);
|
||||
@@ -388,6 +438,7 @@ pub async fn self_transition_answer(
|
||||
alternative: self_transition.label,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
.await
|
||||
@@ -415,8 +466,10 @@ mod tests {
|
||||
responses: serde_json::json!({"name": "Ada"}),
|
||||
state: OPEN_STATE.to_string(),
|
||||
submitted_ms: 1_700_000_000_000,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
@@ -425,8 +478,10 @@ mod tests {
|
||||
responses: serde_json::json!({"name": "Grace"}),
|
||||
state: "invited".to_string(),
|
||||
submitted_ms: 1_700_000_001_000,
|
||||
storyline: 0,
|
||||
decided_ms: Some(1_700_000_002_000),
|
||||
decided_by: Some("bl".to_string()),
|
||||
notes: Vec::new(),
|
||||
},
|
||||
];
|
||||
let value = serde_json::Value::Array(
|
||||
|
||||
+642
-28
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
||||
use crate::auth::{current_user, User};
|
||||
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
|
||||
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition};
|
||||
use crate::i18n::t;
|
||||
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
|
||||
|
||||
@@ -244,12 +244,16 @@ fn QuestionPage() -> impl IntoView {
|
||||
let site = expect_context::<Resource<Result<SiteConfig, ServerFnError>>>();
|
||||
|
||||
view! {
|
||||
// Nothing is written here on purpose. This fallback is the
|
||||
// whole served page until the question resolves - no header,
|
||||
// no wordmark - so any word in it is the first and only thing
|
||||
// a visitor, or a crawler, reads about the site. It used to
|
||||
// say "loading…", which is true and is not about them. The
|
||||
// space is held so the page does not jump when the words
|
||||
// arrive, and `aria-busy` says what is happening to anyone
|
||||
// whose reader would otherwise find an empty document.
|
||||
<Suspense fallback=|| {
|
||||
view! {
|
||||
<main class="loading">
|
||||
<p>"loading…"</p>
|
||||
</main>
|
||||
}
|
||||
view! { <main class="loading" aria-busy="true"></main> }
|
||||
}>
|
||||
{move || {
|
||||
// Both read unconditionally (not nested inside the match
|
||||
@@ -366,10 +370,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 +397,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 +448,7 @@ fn QuestionView(
|
||||
alternative=alt
|
||||
parent_hash=parent_hash.clone()
|
||||
query_email=query_email.clone()
|
||||
place=place_value.clone().unwrap_or_default()
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -414,6 +456,13 @@ fn QuestionView(
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
// The person the page names as responsible, signed in, may
|
||||
// suggest a change to it; the server checks it is them.
|
||||
{question
|
||||
.responsible
|
||||
.as_ref()
|
||||
.filter(|r| is_responsible(user.as_ref(), r))
|
||||
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
|
||||
{question
|
||||
.responsible
|
||||
.clone()
|
||||
@@ -428,10 +477,69 @@ fn QuestionView(
|
||||
/// Every other question the visitor currently qualifies for, as a
|
||||
/// small nav - how a concern reaches the people it speaks to without
|
||||
/// claiming front-page space (an owner also sees the gated desks here).
|
||||
/// Where the link at the foot of a page takes a visitor: the nearest page below
|
||||
/// the current one in the tree that they may open, found by taking the
|
||||
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
|
||||
/// and the front door when none of those is a page. `None` on the front
|
||||
/// door itself, which has nowhere further down to go. It is always a
|
||||
/// valid way on, so a visitor who went up a branch whose context turned
|
||||
/// out not to apply is never left stranded.
|
||||
pub fn down_the_tree(path: &str, pages: &[(String, String)]) -> Option<(String, String)> {
|
||||
let path = path.split(['?', '#']).next().unwrap_or("/").trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut at = path;
|
||||
while let Some(i) = at.rfind('/') {
|
||||
at = &at[..i];
|
||||
let candidate = if at.is_empty() { "/" } else { at };
|
||||
if let Some(page) = pages.iter().find(|(id, _)| id == candidate) {
|
||||
return Some(page.clone());
|
||||
}
|
||||
if at.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(("/".to_string(), String::new()))
|
||||
}
|
||||
|
||||
/// How the way down names where it goes: "the start" for the front
|
||||
/// door, and otherwise the page's own question, quoted - a page's
|
||||
/// name is the question it asks, which reads as a destination only in
|
||||
/// quotation marks.
|
||||
fn down_the_tree_label(lang: &str, href: &str, name: &str) -> String {
|
||||
let to = if href == "/" || name.is_empty() {
|
||||
t(lang, "the_start").to_string()
|
||||
} else if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
|
||||
format!("«{name}»")
|
||||
} else {
|
||||
format!("\u{201c}{name}\u{201d}")
|
||||
};
|
||||
format!("{}{to}", t(lang, "down_to"))
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn QuestionNav(current_id: String) -> impl IntoView {
|
||||
let NavRes(nav) = expect_context();
|
||||
let location = leptos_router::hooks::use_location();
|
||||
view! {
|
||||
// The way down the tree, at the foot of the page, in words:
|
||||
// where a reader who has read to the end looks for where to go
|
||||
// next. It replaces a "back" alternative on every page that had
|
||||
// one.
|
||||
<Suspense fallback=|| ()>
|
||||
{move || {
|
||||
let path = location.pathname.get();
|
||||
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
|
||||
let label = down_the_tree_label(&lang().get(), &href, &name);
|
||||
view! {
|
||||
<nav class="down-the-tree-foot">
|
||||
<a href=href>{format!("← {label}")}</a>
|
||||
</nav>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
<Suspense fallback=|| ()>
|
||||
{move || {
|
||||
let current_id = current_id.clone();
|
||||
@@ -503,6 +611,103 @@ fn Announcements(current_id: String) -> impl IntoView {
|
||||
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
|
||||
/// out of what they see. Not a defense against a targeted scrape, just
|
||||
/// against the generic bot crawl.
|
||||
/// Whether the signed-in person answers for a page: their Kanidm
|
||||
/// address equals the page's `responsible.contact` (`mailto:` off,
|
||||
/// case ignored), or they are in the group that holds it.
|
||||
pub fn is_responsible(user: Option<&User>, responsible: &Responsible) -> bool {
|
||||
let Some(user) = user else { return false };
|
||||
if responsible.group.as_deref().is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) {
|
||||
return true;
|
||||
}
|
||||
let contact = responsible.contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
user.email.as_deref().is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
|
||||
}
|
||||
|
||||
/// One suggestion as the person who made it sees it: what they wrote
|
||||
/// and where it stands.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MySuggestion {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub state: String,
|
||||
pub submitted_ms: i64,
|
||||
}
|
||||
|
||||
/// The translation key for where a suggestion stands. A state the
|
||||
/// content added to the bucket's graph has no words here, and shows as
|
||||
/// itself.
|
||||
fn suggestion_state_key(state: &str) -> Option<&'static str> {
|
||||
match state {
|
||||
"open" => Some("suggestion_open"),
|
||||
"discussed" => Some("suggestion_discussed"),
|
||||
"done" => Some("suggestion_done"),
|
||||
"declined" => Some("suggestion_declined"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A change suggested by the person responsible for the page: one
|
||||
/// field, which becomes a case for whoever keeps the site, and below
|
||||
/// it what became of the ones sent before. Shown only to them.
|
||||
#[component]
|
||||
fn SuggestEdit(question_id: String) -> impl IntoView {
|
||||
let send = ServerAction::<SuggestPageEdit>::new();
|
||||
let text = NodeRef::<leptos::html::Textarea>::new();
|
||||
let sent = move || send.value().get().is_some_and(|r| r.is_ok());
|
||||
let mine = {
|
||||
let question_id = question_id.clone();
|
||||
Resource::new(move || send.version().get(), move |_| my_suggestions(question_id.clone()))
|
||||
};
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let value = text.get().map(|t| t.value()).unwrap_or_default();
|
||||
if !value.trim().is_empty() {
|
||||
send.dispatch(SuggestPageEdit { question_id: question_id.clone(), text: value });
|
||||
}
|
||||
};
|
||||
view! {
|
||||
<section class="suggest-edit">
|
||||
<h2>{move || t(&lang().get(), "suggest_edit")}</h2>
|
||||
<Show
|
||||
when=sent
|
||||
fallback=move || view! {
|
||||
<p>{move || t(&lang().get(), "suggest_edit_help")}</p>
|
||||
<form on:submit=on_submit.clone()>
|
||||
<textarea node_ref=text rows="4" required=true></textarea>
|
||||
<button type="submit" disabled=move || send.pending().get()>{move || t(&lang().get(), "suggest_edit_send")}</button>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<p>{move || t(&lang().get(), "suggest_edit_sent")}</p>
|
||||
</Show>
|
||||
<Transition fallback=|| ()>
|
||||
{move || {
|
||||
mine.get().and_then(|res| res.ok()).filter(|items| !items.is_empty()).map(|items| view! {
|
||||
<h3>{move || t(&lang().get(), "suggestions_yours")}</h3>
|
||||
<ul class="suggest-edit-mine">
|
||||
<For
|
||||
each=move || items.clone()
|
||||
key=|s| (s.id.clone(), s.state.clone())
|
||||
children=|s: MySuggestion| {
|
||||
let state = s.state.clone();
|
||||
let label = move || suggestion_state_key(&state).map(|key| t(&lang().get(), key).to_string()).unwrap_or_else(|| state.clone());
|
||||
view! {
|
||||
<li>
|
||||
<span class="suggest-edit-state" data-state=s.state.clone()>{label}</span>
|
||||
<span class="suggest-edit-text">{s.text}</span>
|
||||
</li>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
})
|
||||
}}
|
||||
</Transition>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
|
||||
let (user, domain) = responsible
|
||||
@@ -591,6 +796,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 +994,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 +1108,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,
|
||||
@@ -967,7 +1205,7 @@ fn AlternativeCard(
|
||||
};
|
||||
let Some(files) = input.files() else { continue };
|
||||
let Some(file) = files.get(0) else { continue };
|
||||
match upload_file(&file).await {
|
||||
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
|
||||
Ok(key) => {
|
||||
map.insert(name.clone(), serde_json::Value::String(key));
|
||||
}
|
||||
@@ -1067,6 +1305,7 @@ fn AlternativeCard(
|
||||
alternative=alt_name.clone()
|
||||
feature_name=feature_name.clone()
|
||||
transitions=spec.transitions.clone()
|
||||
link=spec.link.clone()
|
||||
empty=spec.empty.clone()
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
@@ -1087,6 +1326,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 +1485,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();
|
||||
@@ -1325,7 +1619,12 @@ fn AlternativeCard(
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if req.kind == "hidden" {
|
||||
// `record` carries the case this one belongs to.
|
||||
// Nobody types a chain hash, so it rides in on the
|
||||
// link and is carried, not asked for — the label
|
||||
// row would only ask a person to confirm a string
|
||||
// that means nothing to them.
|
||||
if req.kind == "hidden" || req.kind == "record" {
|
||||
// A carrier value only — no label row, or a
|
||||
// preset key reads as a second visible field.
|
||||
return view! {
|
||||
@@ -1459,6 +1758,7 @@ fn ResourceFeature(
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
link: Option<RowLink>,
|
||||
empty: Option<String>,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
@@ -1489,10 +1789,11 @@ fn ResourceFeature(
|
||||
|
||||
view! {
|
||||
<div class="resource">
|
||||
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
|
||||
<Suspense fallback=|| view! { <p class="resource-loading" aria-busy="true"></p> }>
|
||||
{move || {
|
||||
let feature_name = feature_name.clone();
|
||||
let transitions = transitions.clone();
|
||||
let link = link.clone();
|
||||
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
|
||||
data.get()
|
||||
.map(|res| match res {
|
||||
@@ -1501,6 +1802,7 @@ fn ResourceFeature(
|
||||
<ResourceValue
|
||||
feature_name=feature_name
|
||||
transitions=transitions
|
||||
link=link
|
||||
empty=empty
|
||||
value=value
|
||||
pending_transitions=pending_transitions
|
||||
@@ -1528,6 +1830,7 @@ fn ResourceFeature(
|
||||
fn ResourceValue(
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
link: Option<RowLink>,
|
||||
empty: String,
|
||||
value: serde_json::Value,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
@@ -1559,11 +1862,13 @@ fn ResourceValue(
|
||||
children={
|
||||
let feature_name = feature_name.clone();
|
||||
let transitions = transitions.clone();
|
||||
let link = link.clone();
|
||||
move |answer: Answer| {
|
||||
view! {
|
||||
<AnswerRow
|
||||
feature_name=feature_name.clone()
|
||||
transitions=transitions.clone()
|
||||
link=link.clone()
|
||||
answer=answer
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
@@ -1628,9 +1933,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 +1982,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! {
|
||||
@@ -1731,7 +2051,7 @@ fn SelectField(
|
||||
|
||||
view! {
|
||||
<div class="select-field">
|
||||
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
|
||||
<Suspense fallback=|| view! { <p class="resource-loading" aria-busy="true"></p> }>
|
||||
{move || {
|
||||
let id_field = id_field.clone();
|
||||
data.get()
|
||||
@@ -1807,6 +2127,7 @@ fn SelectField(
|
||||
fn AnswerRow(
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
link: Option<RowLink>,
|
||||
answer: Answer,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
@@ -1835,9 +2156,11 @@ 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());
|
||||
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
|
||||
|
||||
view! {
|
||||
<article class="answer-row" class:decided=move || decided>
|
||||
@@ -1849,6 +2172,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 +2208,32 @@ fn AnswerRow(
|
||||
<span class="answer-state">{state}</span>
|
||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||
</div>
|
||||
// The page about this record, when the list offers one:
|
||||
// where a reader answers a notice.
|
||||
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
|
||||
// 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()
|
||||
@@ -1984,13 +2354,18 @@ pub async fn get_question(
|
||||
path: String,
|
||||
chain: Option<String>,
|
||||
) -> Result<Option<Page>, ServerFnError> {
|
||||
use crate::content::{path_matches, resolve_question};
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::content::{fill_from_user, path_matches, resolve_question};
|
||||
use crate::server::AppState;
|
||||
let state = expect_context::<AppState>();
|
||||
let questions = state.questions.load();
|
||||
let Some(question) = resolve_question(&questions, &path) else {
|
||||
let storylines = state.questions.load();
|
||||
let questions = storylines.at(crate::server::storyline::current().await);
|
||||
let Some(mut question) = resolve_question(questions, &path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
fill_from_user(&mut question, user.as_ref());
|
||||
let mut chain_gate = None;
|
||||
if let Some(target) = &question.requires_chain {
|
||||
// The claimed lineage must verifiably end at the required
|
||||
@@ -2027,7 +2402,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().at(crate::server::storyline::current().await).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,
|
||||
@@ -2056,7 +2436,8 @@ pub async fn list_qualifying_questions(
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let questions = state.questions.load();
|
||||
let storylines = state.questions.load();
|
||||
let questions = storylines.at(crate::server::storyline::current().await);
|
||||
// What the chain verifiably answered - the same lookup a
|
||||
// `requires_chain` gate does, so a forged or foreign hash resolves
|
||||
// to nothing - and therefore where it sent the visitor.
|
||||
@@ -2160,6 +2541,131 @@ pub struct SubmitResult {
|
||||
pub chain_hash: String,
|
||||
}
|
||||
|
||||
/// A change to a page, suggested by the person it names as responsible.
|
||||
/// Checked here, not trusted from the page: the session's address must
|
||||
/// be the page's responsible contact. It becomes a case in
|
||||
/// `portal_edit_suggestions` (open, discussed, done or declined, see
|
||||
/// the built-in aggregates), is announced like any answer and on
|
||||
/// `portal.edits.suggested`, and is mailed to the site's inbox.
|
||||
#[server(endpoint = "suggest_page_edit")]
|
||||
pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::chain::hash_node;
|
||||
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() || text.len() > 8000 {
|
||||
return Err(ServerFnError::new("a suggestion is between one and 8000 characters"));
|
||||
}
|
||||
let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?;
|
||||
let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?;
|
||||
if !is_responsible(user.as_ref(), &responsible) {
|
||||
return Err(ServerFnError::new("only whoever is responsible for this page may suggest changes to it here"));
|
||||
}
|
||||
let user = user.expect("is_responsible needs a user");
|
||||
let email = user.email.clone().unwrap_or_default();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
// The fields a desk shows and a mail may name: who, what, and
|
||||
// which page. `email` is where word of what became of it goes.
|
||||
let responses = serde_json::json!({
|
||||
"name": user.display_name, "email": email, "by": user.username,
|
||||
"page": question_id, "question": question.name, "text": text,
|
||||
});
|
||||
let id = hash_node(&question_id, &[], &responses, now_ms);
|
||||
crate::answers::store_answer(
|
||||
&state.jetstream,
|
||||
&state.aggregates.load(),
|
||||
crate::content::EDIT_SUGGESTIONS_BUCKET,
|
||||
id.clone(),
|
||||
&question_id,
|
||||
SUGGESTION_ALTERNATIVE,
|
||||
&responses,
|
||||
now_ms,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash: id,
|
||||
parent_hashes: Vec::new(),
|
||||
question_id: question_id.clone(),
|
||||
alternative: SUGGESTION_ALTERNATIVE.to_string(),
|
||||
responses: responses.clone(),
|
||||
timestamp_ms: now_ms,
|
||||
storyline: 0,
|
||||
};
|
||||
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
|
||||
tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec(&responses) {
|
||||
let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
|
||||
}
|
||||
crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a suggestion is recorded under, where an answer has the name
|
||||
/// of the alternative that was chosen.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const SUGGESTION_ALTERNATIVE: &str = "Suggested change";
|
||||
|
||||
/// The signed-in person's own suggestions for a page, newest first,
|
||||
/// with where each one stands. Theirs only: matched on the session's
|
||||
/// address, which is what a suggestion was filed under.
|
||||
#[server(endpoint = "my_suggestions")]
|
||||
pub async fn my_suggestions(question_id: String) -> Result<Vec<MySuggestion>, ServerFnError> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let Some(email) = user.and_then(|u| u.email).map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
// Made on first write: nobody has suggested anything yet.
|
||||
let Ok(store) = state.jetstream.get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET).await else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut mine = Vec::new();
|
||||
for key in keys {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
let theirs = answer.responses.get("email").and_then(|v| v.as_str()).is_some_and(|e| e.trim().to_lowercase() == email);
|
||||
if theirs && answer.question_id == question_id {
|
||||
mine.push(MySuggestion {
|
||||
id: answer.id,
|
||||
text: answer.responses.get("text").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
state: answer.state,
|
||||
submitted_ms: answer.submitted_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
mine.sort_by_key(|s| std::cmp::Reverse(s.submitted_ms));
|
||||
Ok(mine)
|
||||
}
|
||||
|
||||
/// Who a principal is, for a record: the username, or the service.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn principal_name(principal: &crate::access::Principal) -> String {
|
||||
match principal {
|
||||
crate::access::Principal::User { sub, .. } => sub.clone(),
|
||||
crate::access::Principal::Submitter { email, .. } => email.clone(),
|
||||
crate::access::Principal::Service { name } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[server(endpoint = "submit_answer")]
|
||||
pub async fn submit_answer(
|
||||
question_id: String,
|
||||
@@ -2177,7 +2683,8 @@ pub async fn submit_answer(
|
||||
// resolve_question, not a plain map get: a dynamic page's concrete
|
||||
// path (what the client holds as its question id) resolves to the
|
||||
// pattern page it came from.
|
||||
let question = crate::content::resolve_question(&state.questions.load(), &question_id)
|
||||
let storyline = crate::server::storyline::current().await;
|
||||
let question = crate::content::resolve_question(state.questions.load().at(storyline), &question_id)
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
@@ -2211,6 +2718,30 @@ pub async fn submit_answer(
|
||||
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
|
||||
|
||||
// A record that belongs to another record: check the other one is
|
||||
// there before this one is written, so a reference in a stored
|
||||
// record is one that resolved at least once. An optional field
|
||||
// left empty points at nothing and is nothing to check.
|
||||
for requirement in alt.features.iter().flat_map(|f| &f.requirements) {
|
||||
let (Some(of), true) = (&requirement.of, requirement.kind == "record") else {
|
||||
continue;
|
||||
};
|
||||
let given = responses.get(&requirement.name).and_then(|v| v.as_str()).map(str::trim).unwrap_or_default();
|
||||
if given.is_empty() {
|
||||
if requirement.optional {
|
||||
continue;
|
||||
}
|
||||
return Err(ServerFnError::new(format!("this answer is missing the {} it is about", requirement.name)));
|
||||
}
|
||||
let found = match state.jetstream.get_key_value(of).await {
|
||||
Ok(store) => store.get(given).await.ok().flatten().is_some(),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !found {
|
||||
return Err(ServerFnError::new(format!("the {} this is about is not on file", requirement.name)));
|
||||
}
|
||||
}
|
||||
|
||||
// An invite: the policy says whether this group may extend one,
|
||||
// Kanidm does the onboarding, and what came back (the account, the
|
||||
// one-time link) is recorded with the answer for the inviter's desk
|
||||
@@ -2227,24 +2758,26 @@ pub async fn submit_answer(
|
||||
if !may.allow {
|
||||
return Err(ServerFnError::new("not authorized to invite into this group"));
|
||||
}
|
||||
let kanidm = crate::people::Kanidm::from_env()
|
||||
.ok_or_else(|| ServerFnError::new("invites are not configured on this instance (KANIDM_API_TOKEN)"))?;
|
||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||
let (name, email) = match (field("name"), field("email")) {
|
||||
(Some(n), Some(e)) => (n, e),
|
||||
_ => return Err(ServerFnError::new("an invite needs a name and an email")),
|
||||
let people = crate::people::People::from_env().ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?;
|
||||
let Some((name, who)) = crate::people::who_for(&responses) else {
|
||||
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
|
||||
};
|
||||
let invited = kanidm
|
||||
.invite(&name, &email, &invite.group)
|
||||
let invited = people
|
||||
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
|
||||
if let Some(map) = responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
|
||||
// Through Kanidm the invite is its own case, with a link that
|
||||
// runs out; a membership is the case already.
|
||||
if people.is_kanidm() {
|
||||
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
|
||||
}
|
||||
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() {
|
||||
@@ -2268,6 +2801,7 @@ pub async fn submit_answer(
|
||||
alternative: alternative.clone(),
|
||||
responses: responses.clone(),
|
||||
timestamp_ms,
|
||||
storyline: storyline as u32,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2293,11 +2827,18 @@ pub async fn submit_answer(
|
||||
&alternative,
|
||||
&responses,
|
||||
timestamp_ms,
|
||||
storyline as u32,
|
||||
)
|
||||
.await
|
||||
{
|
||||
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, storyline as u32).await;
|
||||
crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SubmitResult { next, chain_hash })
|
||||
@@ -2310,12 +2851,21 @@ pub async fn submit_answer(
|
||||
/// server side is a raw Axum multipart handler (`src/upload.rs`) for
|
||||
/// the same reason.
|
||||
#[cfg(feature = "hydrate")]
|
||||
async fn upload_file(file: &web_sys::File) -> Result<String, wasm_bindgen::JsValue> {
|
||||
async fn upload_file(
|
||||
file: &web_sys::File,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
) -> Result<String, wasm_bindgen::JsValue> {
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{FormData, Request, RequestInit, Response};
|
||||
|
||||
let form = FormData::new()?;
|
||||
// Which page and which choice this file is being attached to. The
|
||||
// server refuses an upload that does not name a page it may
|
||||
// submit, so these travel with every file.
|
||||
form.append_with_str("question", question_id)?;
|
||||
form.append_with_str("alternative", alternative)?;
|
||||
form.append_with_blob("file", file)?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
@@ -2338,3 +2888,67 @@ async fn upload_file(file: &web_sys::File) -> Result<String, wasm_bindgen::JsVal
|
||||
.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("upload response had no key"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod down_the_tree_tests {
|
||||
use super::down_the_tree;
|
||||
|
||||
fn pages(ids: &[&str]) -> Vec<(String, String)> {
|
||||
ids.iter().map(|id| (id.to_string(), format!("page {id}"))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_way_down_is_the_nearest_page_below_and_never_a_dead_end() {
|
||||
let p = pages(&["/", "/melde-fra", "/review"]);
|
||||
// One level down, to a page that exists.
|
||||
assert_eq!(down_the_tree("/melde-fra", &p).unwrap().0, "/");
|
||||
assert_eq!(down_the_tree("/review/abc123", &p).unwrap().0, "/review");
|
||||
// Segments that are no page of their own are skipped.
|
||||
assert_eq!(down_the_tree("/status/oppslag", &p).unwrap().0, "/");
|
||||
assert_eq!(down_the_tree("/a/b/c/d", &p).unwrap().0, "/");
|
||||
// A trailing slash or a query does not count as a level.
|
||||
assert_eq!(down_the_tree("/review/abc/?chain=x", &p).unwrap().0, "/review");
|
||||
// Named: the start, or the page's question in quotation marks.
|
||||
assert_eq!(super::down_the_tree_label("no", "/", "Hva gjør vi?"), "Tilbake til starten");
|
||||
assert_eq!(super::down_the_tree_label("no", "/review", "Hva kom inn?"), "Tilbake til «Hva kom inn?»");
|
||||
assert_eq!(super::down_the_tree_label("en", "/review", "What came in?"), "Back to \u{201c}What came in?\u{201d}");
|
||||
// The front door has nowhere further down.
|
||||
assert!(down_the_tree("/", &p).is_none());
|
||||
// A visitor who may not open the front door still gets "/".
|
||||
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod responsible_tests {
|
||||
use super::is_responsible;
|
||||
use crate::auth::User;
|
||||
use crate::content::Responsible;
|
||||
|
||||
fn user(email: Option<&str>, groups: &[&str]) -> User {
|
||||
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: email.map(String::from) }
|
||||
}
|
||||
|
||||
fn named(contact: &str, group: Option<&str>) -> Responsible {
|
||||
Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
|
||||
assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None)));
|
||||
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &[])), &named("kari@vel.no", None)));
|
||||
assert!(!is_responsible(Some(&user(None, &[])), &named("kari@vel.no", None)));
|
||||
assert!(!is_responsible(None, &named("kari@vel.no", None)));
|
||||
assert!(!is_responsible(Some(&user(Some(""), &[])), &named("", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_a_group_answers_for_is_every_members() {
|
||||
let board = named("post@vel.no", Some("styret"));
|
||||
assert!(is_responsible(Some(&user(Some("ola@vel.no"), &["styret"])), &board));
|
||||
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &["trafikk"])), &board));
|
||||
// The inbox itself, signed in, still counts.
|
||||
assert!(is_responsible(Some(&user(Some("post@vel.no"), &[])), &board));
|
||||
assert!(!is_responsible(None, &board));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ pub struct User {
|
||||
/// live, so a group change only takes effect on the next login.
|
||||
#[serde(default)]
|
||||
pub groups: Vec<String>,
|
||||
/// The phone number the provider verified (`phone_number`, from
|
||||
/// userinfo), when it gives one: what a membership is keyed by first.
|
||||
#[serde(default)]
|
||||
pub phone: Option<String>,
|
||||
/// The address Kanidm holds for them (the `email` claim), so a page
|
||||
/// can know it is talking to the person it names as responsible.
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
pub const SESSION_USER_KEY: &str = "user";
|
||||
|
||||
+50
-15
@@ -9,20 +9,27 @@ use super::*;
|
||||
/// config; a file that exists but doesn't parse or validate is a real
|
||||
/// error, surfaced at boot rather than papered over.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> {
|
||||
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Storylines<SiteConfig>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => {
|
||||
tracing::info!("no site.yaml in content repo ({e}), using default branding");
|
||||
return Ok(SiteConfig::default());
|
||||
return Ok(Storylines::one(SiteConfig::default()));
|
||||
}
|
||||
};
|
||||
let site: SiteConfig =
|
||||
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
||||
site.validate()?;
|
||||
Ok(site)
|
||||
// The invite mail's words may be several (see `variants.rs`).
|
||||
let files = vec![("site.yaml".to_string(), raw)];
|
||||
let arity = super::variants::arity(&files)?;
|
||||
let mut each = Vec::with_capacity(arity);
|
||||
for n in 0..arity {
|
||||
let site: SiteConfig =
|
||||
serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
||||
site.validate()?;
|
||||
each.push(site);
|
||||
}
|
||||
Ok(Storylines { each })
|
||||
}
|
||||
|
||||
/// Extracts `scheme://host` from a repo's normal browser URL - the
|
||||
@@ -107,11 +114,18 @@ pub(crate) async fn fetch_gitea_file(
|
||||
pub async fn load_aggregates_from_gitea(
|
||||
repo_url: &str,
|
||||
branch: &str,
|
||||
) -> anyhow::Result<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
|
||||
) -> anyhow::Result<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
||||
crate::aggregates::parse_aggregates_yaml(&raw)
|
||||
// A mail's subject and body may be several (see `variants.rs`);
|
||||
// the runtime's own buckets are added to every storyline.
|
||||
let files = vec![("aggregates.yaml".to_string(), raw)];
|
||||
let arity = super::variants::arity(&files)?;
|
||||
let each = (0..arity)
|
||||
.map(|n| crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1).map(with_builtin_aggregates))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok(Storylines { each })
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -119,7 +133,7 @@ pub async fn load_questions_from_gitea(
|
||||
repo_url: &str,
|
||||
branch: &str,
|
||||
subdir: &str,
|
||||
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
|
||||
) -> anyhow::Result<Storylines<std::collections::HashMap<String, Question>>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
|
||||
@@ -161,7 +175,20 @@ pub async fn load_questions_from_gitea(
|
||||
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
|
||||
files.push((path[prefix.len()..].to_string(), raw));
|
||||
}
|
||||
build_questions(&files)
|
||||
build_storylines(&files)
|
||||
}
|
||||
|
||||
/// Every storyline's arity is the site's or one: a mail told in two
|
||||
/// voices on a site whose pages are told in three has a hole in it.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
|
||||
let arity = questions.max(aggregates).max(site);
|
||||
for (what, n) in [("questions", questions), ("aggregates.yaml", aggregates), ("site.yaml", site)] {
|
||||
if n != 1 && n != arity {
|
||||
anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
|
||||
}
|
||||
}
|
||||
Ok(arity)
|
||||
}
|
||||
|
||||
/// Published by the content repo's own CI (after it lints a push) to
|
||||
@@ -171,6 +198,13 @@ pub async fn load_questions_from_gitea(
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
|
||||
|
||||
/// Published by `watch_for_reload` once new content is in place, for
|
||||
/// whatever acts on what the content says rather than serving it: the
|
||||
/// accounts it mandates (`seed.rs`). No payload; the content is read
|
||||
/// from where it always is.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const CONTENT_RELOADED_SUBJECT: &str = "portal.content.reloaded";
|
||||
|
||||
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
|
||||
/// atomically swaps both `questions` and `aggregates` on every
|
||||
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
|
||||
@@ -185,11 +219,11 @@ pub async fn watch_for_reload(
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
subdir: String,
|
||||
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
|
||||
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
|
||||
aggregates: std::sync::Arc<
|
||||
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
|
||||
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>>,
|
||||
>,
|
||||
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
|
||||
site: std::sync::Arc<arc_swap::ArcSwap<Storylines<SiteConfig>>>,
|
||||
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
|
||||
) {
|
||||
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
|
||||
@@ -202,7 +236,7 @@ pub async fn watch_for_reload(
|
||||
use futures::StreamExt;
|
||||
while sub.next().await.is_some() {
|
||||
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
|
||||
Ok(loaded) => with_builtin_aggregates(loaded),
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
|
||||
continue;
|
||||
@@ -221,7 +255,7 @@ pub async fn watch_for_reload(
|
||||
};
|
||||
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
||||
Ok(loaded) => {
|
||||
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
|
||||
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) {
|
||||
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
|
||||
continue;
|
||||
}
|
||||
@@ -233,6 +267,7 @@ pub async fn watch_for_reload(
|
||||
aggregates.store(std::sync::Arc::new(loaded_aggregates));
|
||||
site.store(std::sync::Arc::new(loaded_site));
|
||||
tracing::info!(count, "reloaded content");
|
||||
let _ = nats.publish(CONTENT_RELOADED_SUBJECT, "".into()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "content reload failed, keeping last-good content");
|
||||
|
||||
+414
-3
@@ -13,12 +13,16 @@ use serde::{Deserialize, Serialize};
|
||||
// Loading is server-only; the rest is shared with the browser build.
|
||||
#[cfg(feature = "ssr")]
|
||||
mod gitea;
|
||||
#[cfg(feature = "ssr")]
|
||||
mod variants;
|
||||
mod handlers;
|
||||
mod markdown;
|
||||
mod validate;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use gitea::*;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use variants::*;
|
||||
pub use handlers::*;
|
||||
pub use markdown::*;
|
||||
pub use validate::*;
|
||||
@@ -84,6 +88,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
|
||||
@@ -162,6 +211,35 @@ impl Question {
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed-in person's own details into the fields that ask for
|
||||
/// them: `value: "{user.email}"` becomes their address and the field
|
||||
/// hidden, since asking someone who is known to say who they are is
|
||||
/// the form doubting them. Without a session the placeholder is
|
||||
/// emptied, and the field asks. Only the whole of a value is filled
|
||||
/// this way; a value that mixes a user placeholder with other text is
|
||||
/// left alone.
|
||||
pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) {
|
||||
for alt in &mut question.alternatives {
|
||||
for feature in &mut alt.features {
|
||||
for req in &mut feature.requirements {
|
||||
let Some(value) = req.value.as_deref() else { continue };
|
||||
let filled = match value {
|
||||
"{user.email}" => user.and_then(|u| u.email.clone()),
|
||||
"{user.name}" => user.map(|u| u.display_name.clone()),
|
||||
_ => continue,
|
||||
};
|
||||
match filled.filter(|v| !v.trim().is_empty()) {
|
||||
Some(v) => {
|
||||
req.value = Some(v);
|
||||
req.kind = "hidden".to_string();
|
||||
}
|
||||
None => req.value = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative's `action` may carry `{field}` / `{field.sub}`
|
||||
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
|
||||
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
|
||||
@@ -222,6 +300,13 @@ pub struct Responsible {
|
||||
/// A mailto address or plain email - deliberately just a string,
|
||||
/// same as everywhere else content keeps contact info simple.
|
||||
pub contact: String,
|
||||
/// The group that holds the responsibility, when it is a group's
|
||||
/// and not one person's: the board's inbox, not the board's
|
||||
/// secretary. Then the address gets no account of its own (see
|
||||
/// `seed.rs`), and any signed-in member of the group may suggest a
|
||||
/// change to the page.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether to render `question`'s alternatives or its sign-in gate for
|
||||
@@ -299,6 +384,33 @@ pub struct SelfTransition {
|
||||
pub bucket: String,
|
||||
pub to: String,
|
||||
pub label: String,
|
||||
/// The states this move may be made from. A person is in dialogue
|
||||
/// with a case for as long as it runs, not only on the day they
|
||||
/// sent it: an applicant written to for a missing drawing answers
|
||||
/// from `incomplete`, and may withdraw from anywhere along the
|
||||
/// way.
|
||||
///
|
||||
/// Empty means the bucket's own initial state, which is the
|
||||
/// one-shot "confirm" or "unsubscribe" link every one of these was
|
||||
/// before it could say otherwise. Its own initial state, not the
|
||||
/// word `open`: a bucket whose records start at `pending` has a
|
||||
/// confirmation link that fires from `pending`, and reading the
|
||||
/// literal here is what made redoal's watch-confirm button a rule
|
||||
/// the engine would never match.
|
||||
#[serde(default)]
|
||||
pub from: Vec<String>,
|
||||
}
|
||||
|
||||
impl SelfTransition {
|
||||
/// The states this move may be made from, with the bucket's
|
||||
/// initial state standing in when the content named none.
|
||||
pub fn from_states(&self, initial: &str) -> Vec<String> {
|
||||
if self.from.is_empty() {
|
||||
vec![initial.to_string()]
|
||||
} else {
|
||||
self.from.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -346,12 +458,35 @@ pub struct ResourceSpec {
|
||||
/// `requires_group` regardless of this flag.
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
/// The record states this resource shows, for a `Kv` source; empty
|
||||
/// shows every one. A register that is public by law is public
|
||||
/// about what was decided, not about what was withdrawn or turned
|
||||
/// down, and the desk that works the same bucket is a different
|
||||
/// spec and still sees all of it.
|
||||
#[serde(default)]
|
||||
pub states: Vec<String>,
|
||||
/// Show only the records whose `where.field` holds `where.equals`,
|
||||
/// for a `Kv` source. With a `type: record` field on the other
|
||||
/// side, this is the half that reads: an application's page lists
|
||||
/// the objections that name it, because each objection stored the
|
||||
/// application's id in a field and this asks for exactly those.
|
||||
/// `equals` takes the page's own `{key}` like a resource key does.
|
||||
#[serde(default, rename = "where")]
|
||||
pub filter: Option<FieldIs>,
|
||||
/// The moves a listed answer may make, one button each - empty
|
||||
/// means read-only. Server calls are checked against this
|
||||
/// allow-list, so a client can never fire a transition content
|
||||
/// didn't declare. `Kv` sources only.
|
||||
#[serde(default)]
|
||||
pub transitions: Vec<Transition>,
|
||||
/// A way from each listed record to a page about it: `to` is the
|
||||
/// page, with `{id}` for the record's id and `{field}` for any of
|
||||
/// its fields, and `label` the words on the link. The other half
|
||||
/// of `type: record`, seen from the list: a notice on a board links
|
||||
/// to the page where a neighbour answers it, and that page's form
|
||||
/// carries the notice's id back in.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<RowLink>,
|
||||
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
|
||||
/// fetched value before it reaches the frontend - e.g.
|
||||
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
|
||||
@@ -373,7 +508,6 @@ pub struct ResourceSpec {
|
||||
pub enum ResourceSource {
|
||||
Kv { bucket: String },
|
||||
GiteaStarred { username: String },
|
||||
GiteaOrgRepos { org: String },
|
||||
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
|
||||
/// for private repos too (shape the link with jq: a private
|
||||
/// release's html_url 404s for anonymous visitors, so map it to
|
||||
@@ -393,7 +527,6 @@ impl ResourceSpec {
|
||||
match &self.source {
|
||||
ResourceSource::Kv { bucket } => Some(bucket),
|
||||
ResourceSource::GiteaStarred { .. }
|
||||
| ResourceSource::GiteaOrgRepos { .. }
|
||||
| ResourceSource::GiteaReleases { .. }
|
||||
| ResourceSource::Url { .. } => None,
|
||||
}
|
||||
@@ -457,9 +590,24 @@ pub struct Requirement {
|
||||
/// A preset value the field starts with. On a dynamic page the
|
||||
/// URL segment substitutes into `{name}` placeholders here, like
|
||||
/// resource keys - `value: "{key}"` on a hidden field carries the
|
||||
/// page's key into the answer.
|
||||
/// page's key into the answer. `{user.email}` and `{user.name}`
|
||||
/// are the signed-in person's, and a field they fill is carried
|
||||
/// rather than asked (see `fill_from_user`); for a visitor they
|
||||
/// are empty, and the field asks as usual.
|
||||
#[serde(default)]
|
||||
pub value: Option<String>,
|
||||
/// `type: record` only - the bucket the referenced record lives in.
|
||||
/// This is how one case belongs to another: an objection names the
|
||||
/// application it objects to, an appeal the decision it appeals.
|
||||
/// The stored value is the parent's record id, and a submission
|
||||
/// naming a record that is not there is refused, so a reference in
|
||||
/// a record is one that resolved at least once.
|
||||
///
|
||||
/// A person never types one of these: the id is a chain hash. It
|
||||
/// arrives the way it was given out - `value: "{key}"` on a dynamic
|
||||
/// page, so the link in the letter carries the case it is about.
|
||||
#[serde(default)]
|
||||
pub of: Option<String>,
|
||||
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
|
||||
/// the drawing widget connects to for live echoes of similar
|
||||
/// strokes. Absent means the widget works offline: the drawn path
|
||||
@@ -473,6 +621,40 @@ pub struct Requirement {
|
||||
/// value. When the sibling changes, `resource` is fetched with the
|
||||
/// value as a parameter and the result replaces this field's value
|
||||
/// (an empty sibling never fetches, and never clears an edit).
|
||||
/// "Show the ones whose `field` is `equals`." The whole of what a
|
||||
/// listing may ask about a record's own contents: one field, one
|
||||
/// value, compared as strings. Anything more is a query language, and
|
||||
/// a jq filter is already there for shaping what comes back.
|
||||
/// A link from every listed record to a page about it (`ResourceSpec.link`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RowLink {
|
||||
pub to: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl RowLink {
|
||||
/// The link for one record: `{id}` is the record's id, `{name}`
|
||||
/// any string field of its responses. A placeholder nothing fills
|
||||
/// stays as it is, which is visible, rather than a wrong page.
|
||||
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
|
||||
let mut out = self.to.replace("{id}", id);
|
||||
if let Some(fields) = responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
if let Some(s) = v.as_str() {
|
||||
out = out.replace(&format!("{{{k}}}"), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FieldIs {
|
||||
pub field: String,
|
||||
pub equals: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Bind {
|
||||
/// The sibling requirement (same alternative) to watch.
|
||||
@@ -543,6 +725,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`
|
||||
@@ -863,6 +1077,25 @@ pub fn resolve_question(
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn build_questions(
|
||||
files: &[(String, String)],
|
||||
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
|
||||
// The first storyline, once every list of texts has been held to
|
||||
// the site's arity (see `variants.rs`); the machinery reads this
|
||||
// one, and `build_storylines` builds the rest for the visitors.
|
||||
variants::arity(files)?;
|
||||
build_one_storyline(&variants::storyline(files, 0))
|
||||
}
|
||||
|
||||
/// Every storyline of the content, each a full map of questions.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
|
||||
let arity = variants::arity(files)?;
|
||||
let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok(variants::Storylines { each })
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn build_one_storyline(
|
||||
files: &[(String, String)],
|
||||
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
|
||||
// Sections first: (directory prefix, config), shallowest first so a
|
||||
// later (deeper) section overrides an outer one field-by-field.
|
||||
@@ -998,6 +1231,94 @@ alternatives:
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
|
||||
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
r#"
|
||||
id: /object
|
||||
name: Object to it
|
||||
alternatives:
|
||||
- name: The register
|
||||
features:
|
||||
- name: All
|
||||
resource:
|
||||
public: true
|
||||
source: {{ kind: kv, bucket: things }}
|
||||
- name: Object
|
||||
record_as: things
|
||||
features:
|
||||
- name: Yours
|
||||
requirements:
|
||||
- name: what
|
||||
{field_yaml}
|
||||
"#
|
||||
))
|
||||
.unwrap();
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
// An objection belongs to the application it objects to. The
|
||||
// bucket it points at has to be one this repo declares, or the
|
||||
// reference could never resolve.
|
||||
#[test]
|
||||
fn a_record_can_belong_to_another_record() {
|
||||
let good = question_with_record(" - { name: case, type: record, of: things }");
|
||||
validate_questions(&good, &schema_fixture()).unwrap();
|
||||
|
||||
let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }");
|
||||
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("has no aggregate declared"), "{err}");
|
||||
|
||||
let unsaid = question_with_record(" - { name: case, type: record }");
|
||||
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("must say `of:"), "{err}");
|
||||
|
||||
// `of` means one thing, so it belongs to one type.
|
||||
let stray = question_with_record(" - { name: case, of: things }");
|
||||
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("only a type: record field has"), "{err}");
|
||||
|
||||
// The id arrives on the link; it is not looked up.
|
||||
let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\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 link it came in on carries the id"), "{err}");
|
||||
}
|
||||
|
||||
// A state that grants a group makes an account for someone, so the
|
||||
// form that fills the bucket has to have asked who they are.
|
||||
#[test]
|
||||
fn a_state_that_grants_a_group_needs_a_name_and_an_address_on_file() {
|
||||
let granting = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: things\n initial: open\n states:\n open: { event: opened }\n middle: { event: advanced }\n done: { event: finished, grants: approved_firms }\n transitions:\n open: [middle]\n middle: [done]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let asks = question_with_record(" - { name: name }\n - { name: email, type: email }");
|
||||
validate_questions(&asks, &granting).unwrap();
|
||||
|
||||
let no_name = question_with_record(" - { name: email, type: email }");
|
||||
let err = validate_questions(&no_name, &granting).unwrap_err();
|
||||
assert!(err.to_string().contains("grant a group"), "{err}");
|
||||
|
||||
// The same form is fine against a schema that grants nothing.
|
||||
validate_questions(&no_name, &schema_fixture()).unwrap();
|
||||
}
|
||||
|
||||
// A deadline may only make a move a person could have made.
|
||||
#[test]
|
||||
fn a_states_deadline_is_held_to_the_same_graph_as_a_button() {
|
||||
let ok = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions:\n open: [no_answer]\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok["hearings"].after_for("open").unwrap().days, 42);
|
||||
|
||||
let undeclared = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n",
|
||||
);
|
||||
assert!(undeclared.unwrap_err().to_string().contains("not a declared transition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_edge_passes() {
|
||||
let questions = question_with_transitions(
|
||||
@@ -1012,6 +1333,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 }");
|
||||
@@ -1493,6 +1884,26 @@ alternatives:
|
||||
assert_eq!(render_inline_markdown("a\nb"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_in_person_is_not_asked_who_they_are() {
|
||||
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
|
||||
let q: Question = serde_yaml::from_str(yaml).unwrap();
|
||||
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
||||
let mut signed_in = q.clone();
|
||||
fill_from_user(&mut signed_in, Some(&user));
|
||||
let reqs = &signed_in.alternatives[0].features[0].requirements;
|
||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
|
||||
// Mixed text is not a user field, and is left as written.
|
||||
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
|
||||
// A visitor is asked: the placeholder goes, the field stays.
|
||||
let mut visitor = q.clone();
|
||||
fill_from_user(&mut visitor, None);
|
||||
let reqs = &visitor.alternatives[0].features[0].requirements;
|
||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_drops_html_and_unsafe_links() {
|
||||
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
|
||||
|
||||
+196
-3
@@ -36,6 +36,12 @@ pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
|
||||
/// runtime's sweeper (`announce.rs`) is what moves the records.
|
||||
pub const EVENTS_BUCKET: &str = "portal_events";
|
||||
|
||||
/// Bucket for changes suggested by the people the content names as
|
||||
/// responsible (`app::suggest_page_edit`): a case like any other, so a
|
||||
/// suggestion is taken up, carried out or turned down by someone at a
|
||||
/// desk, and the person who made it hears which.
|
||||
pub const EDIT_SUGGESTIONS_BUCKET: &str = "portal_edit_suggestions";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
const BUILTIN_AGGREGATES_YAML: &str = "\
|
||||
aggregates:
|
||||
@@ -53,9 +59,33 @@ aggregates:
|
||||
initial: open
|
||||
states:
|
||||
open: { event: invited }
|
||||
expired: { event: expired }
|
||||
done: { event: done }
|
||||
transitions:
|
||||
open: [done]
|
||||
open: [done, expired]
|
||||
expired: [open, done]
|
||||
- bucket: portal_memberships
|
||||
attended_by: the desks that list who is in a group; portal itself moves invited to active at sign-in
|
||||
initial: invited
|
||||
states:
|
||||
invited: { event: invited }
|
||||
active: { event: active }
|
||||
removed: { event: removed }
|
||||
transitions:
|
||||
invited: [active, removed]
|
||||
active: [removed]
|
||||
removed: [invited, active]
|
||||
- bucket: portal_edit_suggestions
|
||||
attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too
|
||||
initial: open
|
||||
states:
|
||||
open: { event: suggested }
|
||||
discussed: { event: discussed }
|
||||
done: { event: done }
|
||||
declined: { event: declined }
|
||||
transitions:
|
||||
open: [discussed, done, declined]
|
||||
discussed: [done, declined]
|
||||
";
|
||||
|
||||
/// Adds the runtime's own state graphs to a content repo's. Content
|
||||
@@ -92,6 +122,82 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mail to the record this one belongs to goes through a field of
|
||||
// the form: `{notice.email}` needs a `type: record` field called
|
||||
// `notice` on every form that records into the bucket, or the mail
|
||||
// has nowhere to go.
|
||||
for (bucket, schema) in aggregates {
|
||||
for (state, spec) in &schema.mail_for_state {
|
||||
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
||||
if !has {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
||||
question.id, alternative.name, spec.to
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A state that grants a group makes an account for the person the
|
||||
// record is about, so the record has to know who they are. Same
|
||||
// shape as an invite's own rule, one step further back: the form
|
||||
// that fills the bucket is where the name and the address have to
|
||||
// be asked for, because by the time a desk presses approve there is
|
||||
// nobody left to ask.
|
||||
for (bucket, schema) in aggregates {
|
||||
let mut granting: Vec<&String> = schema.grants_for_state.keys().collect();
|
||||
if granting.is_empty() {
|
||||
continue;
|
||||
}
|
||||
granting.sort();
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let required = |name: &str, kind: &str| {
|
||||
alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == name && r.kind == kind && !r.optional)
|
||||
};
|
||||
if !required("email", "email") || !required("name", "text") {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} grant a group, so it must collect a required `name` and a required `email` field of type email",
|
||||
question.id, alternative.name, granting
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for question in questions.values() {
|
||||
if let Some(event) = &question.event {
|
||||
if question.is_dynamic() {
|
||||
@@ -101,6 +207,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!(
|
||||
@@ -167,9 +278,12 @@ pub fn validate_questions(
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == name && !r.optional)
|
||||
};
|
||||
if !has("name") || !has("email") {
|
||||
// A person is known by a phone number or an address;
|
||||
// either will do, since memberships key on the number
|
||||
// first and Kanidm on the address.
|
||||
if !has("name") || !(has("email") || has("phone")) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields",
|
||||
"question {:?} alternative {:?}: an invite must collect a required `name` and a required `email` or `phone` field",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
@@ -182,6 +296,24 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
for from in &st.from_states(&schema.initial) {
|
||||
if !schema.has_state(from) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, from, st.bucket
|
||||
);
|
||||
}
|
||||
// The policy would otherwise permit a move the
|
||||
// engine refuses: the reader of the content
|
||||
// should hear about that here, not from a
|
||||
// person who clicked the button.
|
||||
if !schema.allowed(from).iter().any(|s| s == &st.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||
question.id, alternative.name, from, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bind targets resolve across the whole alternative, same
|
||||
@@ -223,6 +355,59 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// One case belonging to another. The bucket has to
|
||||
// be one this repo declares, or the reference could
|
||||
// never resolve; and it is the only thing `of`
|
||||
// means, so it belongs to no other type.
|
||||
if requirement.kind == "record" {
|
||||
let Some(of) = &requirement.of else {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - it must say `of: <bucket>`, the bucket the record it points at lives in",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
};
|
||||
if !aggregates.contains_key(of) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} points at bucket {of:?}, which has no aggregate declared",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if requirement.resource.is_some() || requirement.bind.is_some() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - the link it came in on carries the id, it can't also load one from a resource or bind",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
} else if requirement.of.is_some() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} has `of`, which only a type: record field has",
|
||||
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())
|
||||
@@ -243,6 +428,14 @@ pub fn validate_questions(
|
||||
let Some(schema) = aggregates.get(bucket) else {
|
||||
continue;
|
||||
};
|
||||
for state in &resource.states {
|
||||
if !schema.has_state(state) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: states {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, state, bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
for transition in &resource.transitions {
|
||||
if !schema.has_state(&transition.from) {
|
||||
anyhow::bail!(
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Storylines: a text may be several, and `[n]` runs through the site.
|
||||
//!
|
||||
//! Anywhere content says something to a person - a page's `name` and
|
||||
//! `description`, an alternative's, a feature's, a `label`, a
|
||||
//! `placeholder`, a mail's `subject` and `body`, a list's `empty` line,
|
||||
//! the `consequence` buttons and `encouragements` - it may give a list
|
||||
//! instead of one string:
|
||||
//!
|
||||
//! ```yaml
|
||||
//! description:
|
||||
//! - Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
|
||||
//! - Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
|
||||
//! ```
|
||||
//!
|
||||
//! Every such list has the same length, the site's *arity*, and a
|
||||
//! visitor is given one index for the whole site (a cookie, see
|
||||
//! `main.rs`), so they read one storyline from the front door to the
|
||||
//! mail they get. A single string is every storyline. What a visitor
|
||||
//! did is recorded with the index (`AnswerSubmitted.variant`), so the
|
||||
//! storylines can be compared on what people actually did, and nothing
|
||||
//! else about them.
|
||||
//!
|
||||
//! This is done to the YAML before the structs see it: the files are
|
||||
//! expanded once per storyline, and everything downstream keeps its one
|
||||
//! string per field. Keys that name things to the machine - a field's
|
||||
//! `name`, a select's `options`, states, groups, paths - never vary.
|
||||
//!
|
||||
//! The lint holds every list to the arity, so a storyline cannot have
|
||||
//! a hole in it.
|
||||
|
||||
use serde_yaml::Value;
|
||||
|
||||
/// Keys whose value is one string to a person.
|
||||
const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"];
|
||||
/// Keys whose value is a list of strings to a person; their storylines
|
||||
/// are a list of lists.
|
||||
const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
|
||||
|
||||
/// Where in a document a key is not text but an identifier: a
|
||||
/// requirement's `name` is the stored field, never varied.
|
||||
fn is_identifier(path: &[String], key: &str) -> bool {
|
||||
// `path` ends with the key itself, then the item's index, then the
|
||||
// list it is in.
|
||||
key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements")
|
||||
}
|
||||
|
||||
fn is_strings(seq: &[Value]) -> bool {
|
||||
!seq.is_empty() && seq.iter().all(|v| v.is_string())
|
||||
}
|
||||
|
||||
fn is_lists_of_strings(seq: &[Value]) -> bool {
|
||||
!seq.is_empty() && seq.iter().all(|v| v.as_sequence().is_some_and(|s| s.iter().all(|x| x.is_string())))
|
||||
}
|
||||
|
||||
/// The storyline counts found in a document, with where each was.
|
||||
fn counts(value: &Value, path: &mut Vec<String>, out: &mut Vec<(String, usize)>) {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
for (k, v) in map {
|
||||
let key = k.as_str().unwrap_or_default().to_string();
|
||||
path.push(key.clone());
|
||||
let variants = match v {
|
||||
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq.len()),
|
||||
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq.len()),
|
||||
_ => None,
|
||||
};
|
||||
match variants {
|
||||
Some(n) => out.push((path.join("."), n)),
|
||||
None => counts(v, path, out),
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
for (i, v) in seq.iter().enumerate() {
|
||||
path.push(i.to_string());
|
||||
counts(v, path, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One storyline of a document: every list of texts becomes its `n`th.
|
||||
fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
for (k, v) in map.iter_mut() {
|
||||
let key = k.as_str().unwrap_or_default().to_string();
|
||||
path.push(key.clone());
|
||||
let chosen = match v {
|
||||
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
|
||||
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
|
||||
_ => None,
|
||||
};
|
||||
match chosen {
|
||||
Some(c) => *v = c,
|
||||
None => pick(v, path, n),
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
for (i, v) in seq.iter_mut().enumerate() {
|
||||
path.push(i.to_string());
|
||||
pick(v, path, n);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many storylines these files tell: the length every list of
|
||||
/// texts has, or 1 when there is none. A list of another length is an
|
||||
/// error that names it.
|
||||
pub fn arity(files: &[(String, String)]) -> anyhow::Result<usize> {
|
||||
let mut found: Vec<(String, String, usize)> = Vec::new();
|
||||
for (rel, raw) in files {
|
||||
let Ok(value) = serde_yaml::from_str::<Value>(raw) else { continue };
|
||||
let mut out = Vec::new();
|
||||
counts(&value, &mut Vec::new(), &mut out);
|
||||
found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n)));
|
||||
}
|
||||
let arity = found.iter().map(|(_, _, n)| *n).max().unwrap_or(1);
|
||||
if let Some((rel, p, n)) = found.iter().find(|(_, _, n)| *n != arity) {
|
||||
anyhow::bail!("{rel}: {p} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
|
||||
}
|
||||
Ok(arity.max(1))
|
||||
}
|
||||
|
||||
/// The files as storyline `n` tells them.
|
||||
pub fn storyline(files: &[(String, String)], n: usize) -> Vec<(String, String)> {
|
||||
files
|
||||
.iter()
|
||||
.map(|(rel, raw)| {
|
||||
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else { return (rel.clone(), raw.clone()) };
|
||||
let mut out = Vec::new();
|
||||
counts(&value, &mut Vec::new(), &mut out);
|
||||
if out.is_empty() {
|
||||
return (rel.clone(), raw.clone());
|
||||
}
|
||||
pick(&mut value, &mut Vec::new(), n);
|
||||
(rel.clone(), serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One thing per storyline, and the first as what the machinery reads
|
||||
/// when nobody in particular is asking: a seed, a lint, a desk sweep.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Storylines<T> {
|
||||
pub each: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> Storylines<T> {
|
||||
pub fn one(item: T) -> Self {
|
||||
Storylines { each: vec![item] }
|
||||
}
|
||||
|
||||
pub fn arity(&self) -> usize {
|
||||
self.each.len().max(1)
|
||||
}
|
||||
|
||||
/// Storyline `n`, or the last for an index from a site that told
|
||||
/// more storylines than this one does.
|
||||
pub fn at(&self, n: usize) -> &T {
|
||||
&self.each[n.min(self.each.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Storylines<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &T {
|
||||
&self.each[0]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PAGE: &str = "name: [Vil du hjelpe til?, Trenger naboene deg?]\ndescription: Alle trenger flere hender.\nalternatives:\n - name: [Bli med, Meld deg]\n description: [Si hva du kan., Si hva du har lyst på.]\n consequence: [[Send], [Meld meg]]\n features:\n - name: Skjema\n requirements:\n - name: [navn]\n label: [Navn, Hva heter du?]\n - name: hva\n type: select\n options: [Dugnad, Komité]\n";
|
||||
|
||||
#[test]
|
||||
fn every_list_of_texts_is_a_storyline_and_identifiers_are_not() {
|
||||
let files = vec![("index.yaml".to_string(), PAGE.to_string())];
|
||||
assert_eq!(arity(&files).unwrap(), 2);
|
||||
let second = storyline(&files, 1);
|
||||
let v: Value = serde_yaml::from_str(&second[0].1).unwrap();
|
||||
assert_eq!(v["name"], "Trenger naboene deg?");
|
||||
assert_eq!(v["description"], "Alle trenger flere hender.");
|
||||
assert_eq!(v["alternatives"][0]["name"], "Meld deg");
|
||||
assert_eq!(v["alternatives"][0]["consequence"], serde_yaml::to_value(vec!["Meld meg"]).unwrap());
|
||||
// A requirement's name is the stored field: left as written.
|
||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["name"], serde_yaml::to_value(vec!["navn"]).unwrap());
|
||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["label"], "Hva heter du?");
|
||||
// Options are values people choose from and are stored: untouched.
|
||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][1]["options"], serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused() {
|
||||
let plain = vec![("index.yaml".to_string(), "name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string())];
|
||||
assert_eq!(arity(&plain).unwrap(), 1);
|
||||
assert_eq!(storyline(&plain, 0)[0].1, plain[0].1);
|
||||
let uneven = vec![
|
||||
("index.yaml".to_string(), PAGE.to_string()),
|
||||
("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()),
|
||||
];
|
||||
let err = arity(&uneven).unwrap_err().to_string();
|
||||
assert!(err.contains("b.yaml: name tells 3") && err.contains("tells 2") || err.contains("tells 3"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_storyline_is_what_the_machinery_reads() {
|
||||
let s = Storylines { each: vec!["a", "b"] };
|
||||
assert_eq!(*s, "a");
|
||||
assert_eq!(*s.at(1), "b");
|
||||
assert_eq!(*s.at(7), "b");
|
||||
assert_eq!(s.arity(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! The moves a clock makes rather than a person.
|
||||
//!
|
||||
//! A state in `aggregates.yaml` may carry `after: { days: 42, to:
|
||||
//! no_answer }`: when a record has waited there that long, portal makes
|
||||
//! the move itself. Every deadline a public body keeps is of this
|
||||
//! shape - a hearing goes out and the authority has six weeks to
|
||||
//! answer, and when it does not the case has to go on without one, with
|
||||
//! nobody in the building in a position to press that button.
|
||||
//!
|
||||
//! A deadline may only do what a person could have done: `after.to` is
|
||||
//! held to the same declared edge as a desk button (see
|
||||
//! `parse_aggregates_yaml`), the move goes through the same
|
||||
//! `aggregates::transition`, the same mail fires, and it lands on
|
||||
//! `portal.answers.submitted` like any other decision. What it is not
|
||||
//! is a job queue: the unit is days, the tick is an hour, and a record
|
||||
//! that has waited long enough is moved once, because after the move it
|
||||
//! is no longer in a state that has a deadline.
|
||||
|
||||
use crate::answers::Answer;
|
||||
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
||||
use crate::server::AppState;
|
||||
|
||||
/// Stamped on the record and on the transition event in place of a
|
||||
/// username, so a desk and an n8n workflow can both tell a decision
|
||||
/// nobody made from one somebody did.
|
||||
pub const BY: &str = "deadline";
|
||||
|
||||
const DAY_MS: i64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
pub async fn run(state: AppState) {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Err(e) = sweep(&state).await {
|
||||
tracing::warn!(error = %e, "deadline sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When a record entered the state it is in now. A record nobody has
|
||||
/// moved has been waiting since it was sent.
|
||||
fn waiting_since(answer: &Answer) -> i64 {
|
||||
answer.decided_ms.unwrap_or(answer.submitted_ms)
|
||||
}
|
||||
|
||||
/// Whether a record in `state` has waited out its deadline by `now_ms`.
|
||||
pub fn due(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> Option<String> {
|
||||
let after = schema.after_for(&answer.state)?;
|
||||
let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS;
|
||||
(now_ms >= deadline).then(|| after.to.clone())
|
||||
}
|
||||
|
||||
/// One pass over every bucket that has a deadline on any of its states.
|
||||
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let aggregates = state.aggregates.load();
|
||||
for schema in aggregates.values() {
|
||||
if schema.after_for_state.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A bucket is created on first write, so one that has never
|
||||
// been written to is an ordinary empty bucket, not an error.
|
||||
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else {
|
||||
continue;
|
||||
};
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(mut answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
||||
let Some(target) = due(schema, &answer, now_ms) else { continue };
|
||||
|
||||
let payload = serde_json::json!({ "to": target, "item": id, "by": BY });
|
||||
if let Err(e) = crate::aggregates::transition(
|
||||
&state.jetstream, schema, &id, &target, payload.clone(), now_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// A record whose events are gone, or one another writer
|
||||
// moved between the read and the write: neither is this
|
||||
// sweep's to fix, and the next tick sees it again.
|
||||
tracing::warn!(bucket = %schema.bucket, id, target, "a deadline could not be made: {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
answer.state = target.clone();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(BY.to_string());
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&id, data.into()).await;
|
||||
}
|
||||
crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses, answer.storyline).await;
|
||||
crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
|
||||
|
||||
let parent_hashes = vec![id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
if let Err(e) = emit_answer_submitted(
|
||||
&state.nats,
|
||||
&AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id: answer.question_id.clone(),
|
||||
alternative: target.clone(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: answer.storyline,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(bucket = %schema.bucket, id, "the deadline was made but not published: {e}");
|
||||
}
|
||||
tracing::info!(bucket = %schema.bucket, id, target, "a deadline passed and the case went on");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn schema(yaml: &str) -> crate::aggregates::AggregateSchema {
|
||||
crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap()
|
||||
}
|
||||
|
||||
const HEARINGS: &str = r#"
|
||||
aggregates:
|
||||
- bucket: hearings
|
||||
initial: open
|
||||
states:
|
||||
open: { event: received, after: { days: 42, to: no_answer } }
|
||||
answered: { event: answered }
|
||||
no_answer: { event: no_answer }
|
||||
transitions:
|
||||
open: [answered, no_answer]
|
||||
"#;
|
||||
|
||||
fn waiting(state: &str, since_ms: i64) -> Answer {
|
||||
Answer {
|
||||
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
||||
responses: serde_json::json!({}), state: state.into(),
|
||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
|
||||
storyline: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_comes_due_the_day_it_says_and_not_before() {
|
||||
let s = schema(HEARINGS);
|
||||
let sent = 1_000_000_000_000;
|
||||
let answer = waiting("open", sent);
|
||||
assert_eq!(due(&s, &answer, sent), None);
|
||||
assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None);
|
||||
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), Some("no_answer".into()));
|
||||
}
|
||||
|
||||
// The six weeks run from the last move, not from the day the case
|
||||
// was opened: a record someone touched has been waiting since then.
|
||||
#[test]
|
||||
fn the_wait_is_measured_from_the_last_move() {
|
||||
let s = schema(HEARINGS);
|
||||
let sent = 1_000_000_000_000;
|
||||
let mut answer = waiting("open", sent);
|
||||
answer.decided_ms = Some(sent + 40 * DAY_MS);
|
||||
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None);
|
||||
assert_eq!(due(&s, &answer, sent + 82 * DAY_MS), Some("no_answer".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_with_no_deadline_waits_forever() {
|
||||
let s = schema(HEARINGS);
|
||||
let answer = waiting("answered", 0);
|
||||
assert_eq!(due(&s, &answer, i64::MAX / 2), None);
|
||||
}
|
||||
|
||||
// A clock may only make a move a person could have made, so a
|
||||
// deadline onto an undeclared edge is refused when content loads,
|
||||
// not when the hour comes round.
|
||||
#[test]
|
||||
fn a_deadline_onto_an_undeclared_edge_is_refused_on_load() {
|
||||
let bad = HEARINGS.replace("to: no_answer }", "to: answered }").replace("open: [answered, no_answer]", "open: [no_answer]");
|
||||
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
|
||||
assert!(err.contains("not a declared transition"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_of_no_days_is_refused() {
|
||||
let bad = HEARINGS.replace("days: 42", "days: 0");
|
||||
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
|
||||
assert!(err.contains("at least one"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ pub struct AnswerSubmitted {
|
||||
pub alternative: String,
|
||||
pub responses: serde_json::Value,
|
||||
pub timestamp_ms: i64,
|
||||
/// The storyline the person read (see `content/variants.rs`): what
|
||||
/// the storylines are compared on, and nothing else about them.
|
||||
#[serde(default)]
|
||||
pub storyline: u32,
|
||||
}
|
||||
|
||||
pub const ANSWERS_SUBJECT: &str = "portal.answers.submitted";
|
||||
|
||||
+33
@@ -33,6 +33,17 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
"This part of the site is for organizational owners — sign in with that account to take a look."
|
||||
}
|
||||
("en", "announcements") => "Announcements",
|
||||
("en", "down_to") => "Back to ",
|
||||
("en", "suggest_edit") => "Suggest a change to this page",
|
||||
("en", "suggest_edit_help") => "You are listed as responsible for this question. Say what should change, and it goes to the people who keep the site.",
|
||||
("en", "suggest_edit_send") => "Send the suggestion",
|
||||
("en", "suggest_edit_sent") => "Thank you - your suggestion is sent.",
|
||||
("en", "suggestions_yours") => "What you have suggested here",
|
||||
("en", "suggestion_open") => "Sent",
|
||||
("en", "suggestion_discussed") => "Being looked at",
|
||||
("en", "suggestion_done") => "Done",
|
||||
("en", "suggestion_declined") => "Not done",
|
||||
("en", "the_start") => "the start",
|
||||
|
||||
("no", "also_worth_asking") => "Også verdt å spørre",
|
||||
("no", "asked_by") => "Stilt av ",
|
||||
@@ -51,6 +62,17 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
|
||||
}
|
||||
("no", "announcements") => "Kunngjøringer",
|
||||
("no", "down_to") => "Tilbake til ",
|
||||
("no", "suggest_edit") => "Foreslå en endring på denne siden",
|
||||
("no", "suggest_edit_help") => "Du står oppført som ansvarlig for dette spørsmålet. Skriv hva som bør endres, så går det til dem som har siden.",
|
||||
("no", "suggest_edit_send") => "Send forslaget",
|
||||
("no", "suggest_edit_sent") => "Takk - forslaget er sendt.",
|
||||
("no", "suggestions_yours") => "Det du har foreslått her",
|
||||
("no", "suggestion_open") => "Sendt",
|
||||
("no", "suggestion_discussed") => "Blir sett på",
|
||||
("no", "suggestion_done") => "Gjort",
|
||||
("no", "suggestion_declined") => "Ikke gjort",
|
||||
("no", "the_start") => "starten",
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
@@ -83,6 +105,17 @@ mod tests {
|
||||
"follows_answer",
|
||||
"owners_only",
|
||||
"announcements",
|
||||
"down_to",
|
||||
"the_start",
|
||||
"suggest_edit",
|
||||
"suggest_edit_help",
|
||||
"suggest_edit_send",
|
||||
"suggest_edit_sent",
|
||||
"suggestions_yours",
|
||||
"suggestion_open",
|
||||
"suggestion_discussed",
|
||||
"suggestion_done",
|
||||
"suggestion_declined",
|
||||
] {
|
||||
assert!(lookup("en", key).is_some(), "en missing {key}");
|
||||
assert!(lookup("no", key).is_some(), "no missing {key}");
|
||||
|
||||
+18
@@ -13,6 +13,12 @@ pub mod aggregates;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod announce;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod deadlines;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod sessions;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod http;
|
||||
|
||||
@@ -22,6 +28,18 @@ pub mod access;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod people;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod mail;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod seed;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod memberships;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod report;
|
||||
|
||||
pub mod maps;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
|
||||
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// The record field a `to` reaches through, when it is neither
|
||||
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||
pub fn parent_of(to: &str) -> Option<&str> {
|
||||
let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?;
|
||||
let (field, _) = inner.split_once('.')?;
|
||||
(!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field)
|
||||
}
|
||||
|
||||
/// The fields of every record this one belongs to, as `{field.name}`:
|
||||
/// for each `type: record` field of the forms that record into
|
||||
/// `bucket`, the record it names, read from its own bucket. A
|
||||
/// reference that does not resolve adds nothing, and a template that
|
||||
/// needed it renders empty there.
|
||||
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
|
||||
let questions = state.questions.load();
|
||||
let refs: Vec<(String, String)> = questions
|
||||
.values()
|
||||
.flat_map(|q| q.alternatives.iter())
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket))
|
||||
.flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter()))
|
||||
.filter(|r| r.kind == "record")
|
||||
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
|
||||
.collect();
|
||||
for (field, of) in refs {
|
||||
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
|
||||
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { continue };
|
||||
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
if let Some(fields) = parent.responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
let text = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
vars.insert(format!("{field}.{k}"), text.into());
|
||||
}
|
||||
}
|
||||
vars.insert(format!("{field}.id"), parent.id.into());
|
||||
vars.insert(format!("{field}.state"), parent.state.into());
|
||||
}
|
||||
}
|
||||
|
||||
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, storyline: u32) {
|
||||
// The mail tells the storyline the person read.
|
||||
let storyline = storyline as usize;
|
||||
let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
||||
// A suggestion has its own words for this unless the content
|
||||
// gave the state a mail, which then is the one sent.
|
||||
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
||||
on_suggestion_state(state, id, to_state, responses).await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let site_storylines = state.site.load();
|
||||
let site = site_storylines.at(storyline);
|
||||
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 mut vars = vars_for(bucket, id, to_state, responses);
|
||||
parent_vars(state, bucket, responses, &mut vars).await;
|
||||
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 {
|
||||
// A field of the record this one belongs to, or an address.
|
||||
let to = render(&spec.to, &vars);
|
||||
if !to.contains('@') {
|
||||
tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent");
|
||||
return;
|
||||
}
|
||||
to
|
||||
};
|
||||
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 questions a person answers for, as a mail names them: each in
|
||||
/// quotes, and past the first three only how many more - someone who
|
||||
/// answers for a whole site is not read its table of contents.
|
||||
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
||||
let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
|
||||
let and = if norwegian { "og" } else { "and" };
|
||||
match questions {
|
||||
[] => String::new(),
|
||||
[one] => quote(one),
|
||||
few if few.len() <= 3 => {
|
||||
let quoted: Vec<String> = few.iter().map(quote).collect();
|
||||
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
|
||||
}
|
||||
many => {
|
||||
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
|
||||
let more = many.len() - 3;
|
||||
let others = match (norwegian, more) {
|
||||
(true, 1) => "ett spørsmål til".to_string(),
|
||||
(true, n) => format!("{n} spørsmål til"),
|
||||
(false, 1) => "one more question".to_string(),
|
||||
(false, n) => format!("{n} more questions"),
|
||||
};
|
||||
format!("{} {and} {others}", quoted.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mail a person gets when the content names them responsible for
|
||||
/// a question and they had no account: who they are to the site (the
|
||||
/// person listed as asking that question), and the link that lets them
|
||||
/// sign in and suggest changes to it. Built in, in the site's language.
|
||||
pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let list = questions_listed(questions, norwegian);
|
||||
let (subject, body) = if norwegian {
|
||||
(
|
||||
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
|
||||
"Hei {name},\n\nDu står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak.\n\nVelg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"You are listed as responsible for a question at {site_name}".to_string(),
|
||||
"Hello {name},\n\nYou are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind.\n\nSet your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}. Afterwards you sign in at {site}.".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("questions".into(), list.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!("responsible:{}:{now_ms}", invited.username),
|
||||
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 responsible mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the person who suggested a change hears when it moves, as
|
||||
/// subject and body; `None` for a state that says nothing new to them
|
||||
/// (it arriving, or one the content added). `{name}`, `{question}`,
|
||||
/// `{page}`, `{quoted}`.
|
||||
pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str, &'static str)> {
|
||||
Some(match (to_state, norwegian) {
|
||||
("discussed", true) => (
|
||||
"Forslaget ditt om «{question}» blir sett på",
|
||||
"Hei {name},\n\nForslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("done", true) => (
|
||||
"Endringen du foreslo i «{question}» er gjort",
|
||||
"Hei {name},\n\nEndringen du foreslo på {page} er gjort. Takk for at du sa fra.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("declined", true) => (
|
||||
"Forslaget ditt om «{question}» blir ikke gjort",
|
||||
"Hei {name},\n\nForslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("discussed", false) => (
|
||||
"Your suggestion about \u{201c}{question}\u{201d} is being looked at",
|
||||
"Hello {name},\n\nYour suggested change to {page} has been taken up and is being looked at. You will hear when it is settled.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
("done", false) => (
|
||||
"The change you suggested to \u{201c}{question}\u{201d} is made",
|
||||
"Hello {name},\n\nThe change you suggested to {page} is made. Thank you for saying so.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
("declined", false) => (
|
||||
"Your suggestion about \u{201c}{question}\u{201d} will not be made",
|
||||
"Hello {name},\n\nYour suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A suggestion moved: tell whoever made it. Replies go to the site's
|
||||
/// inbox, which is who decided.
|
||||
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
|
||||
let site = state.site.load();
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
|
||||
return;
|
||||
};
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
|
||||
return;
|
||||
};
|
||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
let to = field("email");
|
||||
if !to.contains('@') {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
|
||||
return;
|
||||
}
|
||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("name".into(), field("name").into());
|
||||
vars.insert("question".into(), field("question").into());
|
||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
||||
vars.insert("quoted".into(), quoted.into());
|
||||
let text = render(body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to,
|
||||
subject: render(subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(id, to_state, "publishing the suggestion mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A change suggested by the person responsible for a page, to the
|
||||
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
|
||||
/// suggester as reply-to so answering the mail answers them.
|
||||
pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
|
||||
return;
|
||||
};
|
||||
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, lead) = if norwegian {
|
||||
(format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
|
||||
} else {
|
||||
(format!("Suggested change: \u{201c}{question_name}\u{201d}"), format!("{by_name} ({by_email}), listed as responsible for the question, suggests a change to {}{question_id}:", public_url()))
|
||||
};
|
||||
let body = format!("{lead}\n\n{text}");
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("suggestion:{question_id}:{now_ms}"),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: Some(by_email.to_string()),
|
||||
to,
|
||||
subject,
|
||||
html: to_html(&body),
|
||||
text: body,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(question_id, "publishing the suggestion 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\nDu har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der.".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\nYou now have a place in {group} at {site_name}. Sign in at {site}, and it is there.".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 a_mail_names_three_questions_and_counts_the_rest() {
|
||||
let q = |n: usize| (1..=n).map(|i| format!("Q{i}?")).collect::<Vec<_>>();
|
||||
assert_eq!(questions_listed(&q(0), true), "");
|
||||
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
|
||||
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
|
||||
assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
|
||||
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
|
||||
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
|
||||
assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival() {
|
||||
for norwegian in [true, false] {
|
||||
for state in ["discussed", "done", "declined"] {
|
||||
let (subject, body) = suggestion_words(state, norwegian).expect(state);
|
||||
assert!(subject.contains("{question}"), "{state}: the subject names the question");
|
||||
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
|
||||
}
|
||||
// Arriving is told to the inbox, not back to who sent it.
|
||||
assert!(suggestion_words("open", norwegian).is_none());
|
||||
assert!(suggestion_words("a_state_the_content_added", norwegian).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_reaches_through_a_record_field_and_nothing_else() {
|
||||
assert_eq!(parent_of("{notice.email}"), Some("notice"));
|
||||
assert_eq!(parent_of(" {case.contact} "), Some("case"));
|
||||
assert_eq!(parent_of("submitter"), None);
|
||||
assert_eq!(parent_of("post@vel.no"), None);
|
||||
assert_eq!(parent_of("{email}"), None);
|
||||
assert_eq!(parent_of("{.email}"), None);
|
||||
assert_eq!(parent_of("{not a.field}"), None);
|
||||
}
|
||||
|
||||
#[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,
|
||||
storyline: 0,
|
||||
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");
|
||||
}
|
||||
}
|
||||
+105
-11
@@ -10,7 +10,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
use portal::upload::{self, Garage};
|
||||
use std::sync::Arc;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_sessions::{MemoryStore, SessionManagerLayer};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
@@ -30,13 +30,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
|
||||
let gitea_base = content::gitea_api_base(&content_repo)?;
|
||||
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
||||
let aggregates = content::with_builtin_aggregates(
|
||||
content::load_aggregates_from_gitea(&content_repo, &content_branch).await?,
|
||||
);
|
||||
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
|
||||
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||
content::validate_questions(&questions, &aggregates)?;
|
||||
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
||||
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
|
||||
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?;
|
||||
tracing::info!(count = questions.len(), storylines, repo = %content_repo, branch = %content_branch, "loaded content");
|
||||
let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates)));
|
||||
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
|
||||
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
||||
@@ -60,6 +59,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?);
|
||||
|
||||
@@ -78,9 +85,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
site.clone(),
|
||||
access.clone(),
|
||||
));
|
||||
// The same decisions, answerable by other apps over NATS.
|
||||
tokio::spawn(portal::access::serve(nats.clone(), site_name_for_subjects(), access.clone()));
|
||||
|
||||
let state = AppState {
|
||||
leptos_options: leptos_options.clone(),
|
||||
nats,
|
||||
@@ -96,9 +100,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
garage,
|
||||
};
|
||||
|
||||
// The same decisions, answerable by other apps over NATS, for a
|
||||
// person named by phone number or address as well as by groups.
|
||||
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
|
||||
// Announced pages: keep their desk records current, close ended
|
||||
// windows (see announce.rs).
|
||||
tokio::spawn(portal::announce::run(state.clone()));
|
||||
tokio::spawn(portal::deadlines::run(state.clone()));
|
||||
// The accounts the content mandates: the first person in, and
|
||||
// everyone a page names as responsible, at start and whenever the
|
||||
// content changes (seed.rs).
|
||||
tokio::spawn(portal::seed::run(state.clone()));
|
||||
// Invites whose link ran out, or whose person is in (people.rs).
|
||||
tokio::spawn(portal::people::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
|
||||
@@ -113,11 +132,50 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cookie_secure = std::env::var("COOKIE_SECURE")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
||||
// Sessions live in the same JetStream as everything else, so a
|
||||
// deploy no longer signs out every desk on the site it deploys.
|
||||
let session_store = portal::sessions::NatsStore::open(&state.jetstream).await?;
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(cookie_secure)
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_name("portal_session");
|
||||
|
||||
// What a person does costs a little; what a script does costs a
|
||||
// lot. A burst of 30 with one refilled every two seconds is well
|
||||
// above anything a visitor filling in a form will reach - a page
|
||||
// load fetches several resources at once - and well below what it
|
||||
// takes to be a nuisance. Per caller IP, in memory, no store: this
|
||||
// is a fence, not an accounting system, and the fence that matters
|
||||
// for uploads is the policy check on the route itself.
|
||||
//
|
||||
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
|
||||
// 0 for either turns it off, for an instance behind something that
|
||||
// already does this.
|
||||
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2);
|
||||
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
|
||||
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
|
||||
// what every one of these instances is behind; it falls back to
|
||||
// the socket address when neither is set.
|
||||
let conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.per_second(rate_seconds)
|
||||
.burst_size(rate_burst)
|
||||
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("a rate limit of at least one per second and a burst of at least one");
|
||||
std::sync::Arc::new(conf)
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
@@ -143,6 +201,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()
|
||||
@@ -157,10 +219,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
get(upload::attachment),
|
||||
)
|
||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
||||
// What the storylines did, as counts (report.rs).
|
||||
.route("/report", get(portal::report::handler))
|
||||
// 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 +259,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,
|
||||
@@ -199,11 +275,29 @@ async fn main() -> anyhow::Result<()> {
|
||||
)
|
||||
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||
.layer(session_layer)
|
||||
.with_state(state);
|
||||
// Which storyline this visitor reads, before anything renders.
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
|
||||
// Outermost, so a caller who is over the limit is turned away
|
||||
// before a session is loaded or a handler is entered.
|
||||
let app = match governor {
|
||||
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
|
||||
None => app,
|
||||
}
|
||||
.with_state(state);
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app.into_make_service()).await?;
|
||||
// With connect info, not without: the rate limiter reads the
|
||||
// caller from `X-Forwarded-For`/`X-Real-IP` and falls back to the
|
||||
// socket, and without this there is no socket to fall back to. A
|
||||
// request that arrives without those headers - a health check on
|
||||
// localhost, anything not through Caddy - then has no key at all,
|
||||
// and the limiter answers 500 to every one of them.
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Who belongs to which group, kept by portal itself.
|
||||
//!
|
||||
//! A membership is a case: one record per (group, person) in the
|
||||
//! built-in `portal_memberships` bucket, `invited` until the person is
|
||||
//! seen, `active` from then on, `removed` when a desk takes them out.
|
||||
//! A desk invite writes one, a state that `grants` a group writes one,
|
||||
//! the first person and the people the content names as responsible
|
||||
//! get theirs at start. Nothing here talks to an identity provider:
|
||||
//! the provider says who someone is, and this says what they belong
|
||||
//! to. At sign-in, the person's active memberships become their groups,
|
||||
//! and the access policy (Cedar, `access.rs`) sees them as the parents
|
||||
//! of the `User` entity, exactly as it saw a `groups` claim before.
|
||||
//!
|
||||
//! **A person is their phone number first, their email second.** A
|
||||
//! provider that verifies phone numbers (Vipps) makes the number the
|
||||
//! surest thing a record can hold; a form that collected an address
|
||||
//! still matches on it. A record's key is the group and whichever of
|
||||
//! the two it has, so a second invite of the same person into the same
|
||||
//! group finds the first.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::answers::Answer;
|
||||
|
||||
pub const BUCKET: &str = "portal_memberships";
|
||||
pub const INVITED: &str = "invited";
|
||||
pub const ACTIVE: &str = "active";
|
||||
pub const REMOVED: &str = "removed";
|
||||
/// Stamped on moves nobody at a desk made.
|
||||
pub const BY: &str = "portal";
|
||||
|
||||
/// The country a phone number without one is in, as a calling code.
|
||||
fn default_country() -> String {
|
||||
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string())
|
||||
}
|
||||
|
||||
/// Who a person is, as far as a record or a session knows.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Identity {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sub: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// A phone number as one string for everyone: digits with a leading
|
||||
/// `+` and a country code, `+4791234567`. Spaces, dots and dashes go;
|
||||
/// `00` becomes `+`; a bare national number gets the site's country.
|
||||
/// `None` for anything that is not a number.
|
||||
pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
|
||||
let mut digits: String = raw.trim().chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let trimmed = raw.trim();
|
||||
if digits.is_empty() || digits.len() < 5 || digits.len() > 15 {
|
||||
return None;
|
||||
}
|
||||
if trimmed.starts_with('+') {
|
||||
return Some(format!("+{digits}"));
|
||||
}
|
||||
if let Some(rest) = digits.strip_prefix("00") {
|
||||
return Some(format!("+{rest}"));
|
||||
}
|
||||
if trimmed.starts_with('(') && !trimmed.contains('+') {
|
||||
digits = digits.trim_start_matches('0').to_string();
|
||||
}
|
||||
Some(format!("{}{digits}", country.trim()))
|
||||
}
|
||||
|
||||
fn normalize_email(raw: &str) -> Option<String> {
|
||||
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// From what a form collected: the first phone-like field and the
|
||||
/// first email-like field, by the names forms use for them.
|
||||
pub fn from_responses(responses: &serde_json::Value) -> Self {
|
||||
let field = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|n| responses.get(*n).and_then(|v| v.as_str()))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
};
|
||||
let country = default_country();
|
||||
Identity {
|
||||
sub: None,
|
||||
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
|
||||
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_user(user: &crate::auth::User) -> Self {
|
||||
let country = default_country();
|
||||
Identity {
|
||||
sub: Some(user.sub.clone()),
|
||||
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
|
||||
email: user.email.as_deref().and_then(normalize_email),
|
||||
}
|
||||
}
|
||||
|
||||
/// One person given as one string: a phone number if it reads as
|
||||
/// one, else an address.
|
||||
pub fn from_contact(contact: &str) -> Self {
|
||||
let country = default_country();
|
||||
match normalize_email(contact) {
|
||||
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
|
||||
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// What a membership record is keyed by: the phone number, else the
|
||||
/// address, else the provider's subject. `None` when there is
|
||||
/// nothing to know the person by.
|
||||
pub fn key(&self) -> Option<String> {
|
||||
self.phone
|
||||
.as_deref()
|
||||
.or(self.email.as_deref())
|
||||
.or(self.sub.as_deref())
|
||||
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
|
||||
}
|
||||
|
||||
/// Whether a record about `other` is about this person: the same
|
||||
/// subject, phone number or address, any one of them.
|
||||
pub fn matches(&self, other: &Identity) -> bool {
|
||||
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
|
||||
same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email)
|
||||
}
|
||||
|
||||
/// What is worth writing on a record from a login: everything the
|
||||
/// provider verified that the record did not have.
|
||||
fn merged_from(&self, seen: &Identity) -> Identity {
|
||||
Identity {
|
||||
sub: self.sub.clone().or_else(|| seen.sub.clone()),
|
||||
phone: self.phone.clone().or_else(|| seen.phone.clone()),
|
||||
email: self.email.clone().or_else(|| seen.email.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One record's id: the group and the person's key.
|
||||
pub fn record_id(group: &str, key: &str) -> String {
|
||||
format!("{}.{key}", group.trim())
|
||||
}
|
||||
|
||||
/// The identity a record holds.
|
||||
pub fn identity_of(answer: &Answer) -> Identity {
|
||||
Identity {
|
||||
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_of(answer: &Answer) -> String {
|
||||
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// The groups `who` is an active member of, from every record.
|
||||
pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
|
||||
let mut groups: Vec<String> = records
|
||||
.iter()
|
||||
.filter(|a| a.state == ACTIVE && identity_of(a).matches(who))
|
||||
.map(group_of)
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
groups
|
||||
}
|
||||
|
||||
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
||||
match state.jetstream.get_key_value(BUCKET).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(_) => Ok(state
|
||||
.jetstream
|
||||
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
|
||||
.await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every membership record.
|
||||
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
||||
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() };
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for key in keys {
|
||||
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||
if let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) {
|
||||
out.push(answer);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Moves one record the way a desk button would: through the event
|
||||
/// log, onto the projection, out on the answers subject.
|
||||
async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result<Answer> {
|
||||
let aggregates = state.aggregates.load();
|
||||
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
|
||||
crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
answer.state = target.to_string();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(by.to_string());
|
||||
store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?;
|
||||
let parent_hashes = vec![answer.id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let event = crate::events::AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id: answer.question_id.clone(),
|
||||
alternative: target.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Puts a person in a group, or finds them there. A new record starts
|
||||
/// `invited`; a removed one is invited again; an invited or active one
|
||||
/// is left as it is, so a second grant costs nothing. `created` in
|
||||
/// what comes back says whether the person had a place already.
|
||||
pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result<crate::people::Invited> {
|
||||
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
|
||||
let store = store(state).await?;
|
||||
// The person's record in this group, however it was keyed: a desk
|
||||
// that invited by address and a decision that granted by phone
|
||||
// number are talking about one person once a sign-in has joined
|
||||
// the two, and must not make two.
|
||||
let existing: Option<Answer> = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
|
||||
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let invited = |created: bool| crate::people::Invited {
|
||||
username: key.clone(),
|
||||
display_name: name.to_string(),
|
||||
email: who.email.clone().unwrap_or_default(),
|
||||
group: group.to_string(),
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created,
|
||||
already_member: !created,
|
||||
};
|
||||
match existing {
|
||||
Some(answer) if answer.state == REMOVED => {
|
||||
move_to(state, &store, answer, INVITED, by).await?;
|
||||
Ok(invited(true))
|
||||
}
|
||||
Some(_) => Ok(invited(false)),
|
||||
None => {
|
||||
let responses = serde_json::json!({
|
||||
"name": name, "group": group, "by": by,
|
||||
"phone": who.phone, "email": who.email, "sub": who.sub,
|
||||
});
|
||||
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?;
|
||||
Ok(invited(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a person out of a group: the record moves to `removed` and
|
||||
/// stays, so the desk can see who was in and put them back.
|
||||
pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result<bool> {
|
||||
let Some(key) = who.key() else { return Ok(false) };
|
||||
let store = store(state).await?;
|
||||
let id = record_id(group, &key);
|
||||
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if answer.state == REMOVED {
|
||||
return Ok(false);
|
||||
}
|
||||
move_to(state, &store, answer, REMOVED, by).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Someone signed in: every invitation that was for them is now a
|
||||
/// membership, with what the provider verified written on the record,
|
||||
/// and their active groups come back to be their groups for the
|
||||
/// session. Their `sub` is remembered, so the next login matches on
|
||||
/// it before anything else.
|
||||
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
|
||||
let who = Identity::from_user(user);
|
||||
let records = all(state).await;
|
||||
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
|
||||
let mut settled = Vec::with_capacity(records.len());
|
||||
for answer in records {
|
||||
let held = identity_of(&answer);
|
||||
if !held.matches(&who) {
|
||||
settled.push(answer);
|
||||
continue;
|
||||
}
|
||||
let merged = held.merged_from(&who);
|
||||
let mut answer = answer;
|
||||
if merged != held {
|
||||
if let Some(map) = answer.responses.as_object_mut() {
|
||||
map.insert("sub".into(), serde_json::json!(merged.sub));
|
||||
map.insert("phone".into(), serde_json::json!(merged.phone));
|
||||
map.insert("email".into(), serde_json::json!(merged.email));
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&answer.id, bytes.into()).await;
|
||||
}
|
||||
}
|
||||
if answer.state == INVITED {
|
||||
match move_to(state, &store, answer.clone(), ACTIVE, BY).await {
|
||||
Ok(active) => {
|
||||
tracing::info!(group = %group_of(&active), user = %user.username, "an invitation became a membership");
|
||||
settled.push(active);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id = %answer.id, "an invitation could not become a membership: {e}");
|
||||
settled.push(answer);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settled.push(answer);
|
||||
}
|
||||
}
|
||||
groups_of(&settled, &who)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
|
||||
Answer {
|
||||
id: record_id(group, "k"),
|
||||
question_id: "/".into(),
|
||||
alternative: "Membership".into(),
|
||||
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
|
||||
state: state.into(),
|
||||
submitted_ms: 0,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_number_is_one_string_for_everyone() {
|
||||
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into()));
|
||||
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
|
||||
assert_eq!(normalize_phone("123", "+47"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_is_their_phone_first_and_their_address_second() {
|
||||
let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }));
|
||||
assert_eq!(both.key().as_deref(), Some("_4791234567"));
|
||||
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
||||
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
|
||||
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
|
||||
assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no"));
|
||||
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
|
||||
assert!(Identity::default().key().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
|
||||
let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
|
||||
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
|
||||
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
|
||||
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
|
||||
assert!(!Identity::default().matches(&kari));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_memberships_are_groups() {
|
||||
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||
let records = vec![
|
||||
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
||||
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
||||
record("trafikk", REMOVED, Some("+4791234567"), None, None),
|
||||
record("medlemmer", ACTIVE, None, Some("kari@vel.no"), None),
|
||||
record("styret", ACTIVE, Some("+4799999999"), None, None),
|
||||
];
|
||||
assert_eq!(groups_of(&records, &kari), vec!["medlemmer", "styret"]);
|
||||
}
|
||||
}
|
||||
+631
-38
@@ -8,14 +8,108 @@
|
||||
//! Talks to Kanidm's REST API with a service-account token
|
||||
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
|
||||
//! service account belongs to `idm_people_on_boarding`, which may
|
||||
//! create persons and issue first-credential resets and nothing else,
|
||||
//! so a leaked token cannot touch existing credentials or admins.
|
||||
//! create persons and issue credential resets for ordinary people and
|
||||
//! nothing else, so a leaked token cannot touch an admin.
|
||||
//!
|
||||
//! **An invite is a case.** Each one is a record in `portal_invites`:
|
||||
//! `open` while the link waits, `done` once the person is in, `expired`
|
||||
//! when the link ran out unused. Nobody has to keep that current by
|
||||
//! hand: signing in to the site closes a person's invites, and a sweep
|
||||
//! moves the ones whose link has run out (`sweep_invites`). From
|
||||
//! `expired` a desk may send it again - the move back to `open` makes a
|
||||
//! fresh link and mails it (`on_reopened`).
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const INVITES_BUCKET: &str = "portal_invites";
|
||||
|
||||
/// Where people are kept. Kanidm makes accounts and holds groups; with
|
||||
/// memberships (`memberships.rs`) portal holds the groups itself and
|
||||
/// the identity provider only says who someone is. `PEOPLE_BACKEND`
|
||||
/// picks; unset, a `KANIDM_API_TOKEN` means Kanidm and its absence
|
||||
/// means memberships, so a site with no Kanidm token can still invite.
|
||||
pub enum People {
|
||||
Kanidm(Kanidm),
|
||||
Memberships,
|
||||
}
|
||||
|
||||
impl People {
|
||||
/// `None` only when Kanidm is asked for and not configured.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
|
||||
Some("memberships") => Some(People::Memberships),
|
||||
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
|
||||
Some(other) => {
|
||||
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
|
||||
Some(People::Memberships)
|
||||
}
|
||||
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is missing when `from_env` says no.
|
||||
pub const NOT_CONFIGURED: &'static str = "invites are not configured on this instance (PEOPLE_BACKEND=kanidm needs KANIDM_URL and KANIDM_API_TOKEN)";
|
||||
|
||||
/// Puts a person in a group, making them known if they were not:
|
||||
/// through Kanidm an account and a one-time link, through
|
||||
/// memberships a record that becomes theirs when they sign in.
|
||||
/// `by` is who asked, for the record.
|
||||
pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result<Invited> {
|
||||
match self {
|
||||
People::Kanidm(kanidm) => {
|
||||
let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?;
|
||||
kanidm.invite(name, email, group).await
|
||||
}
|
||||
People::Memberships => {
|
||||
let mut invited = crate::memberships::grant(state, group, name, who, by).await?;
|
||||
// A Kanidm beside memberships makes the person, not the
|
||||
// member: an account with no group, and the one-time
|
||||
// link, so someone invited by address can sign in
|
||||
// before the provider is one everyone already has.
|
||||
if let (Some(kanidm), Some(email)) = (Kanidm::from_env(), who.email.as_deref()) {
|
||||
match kanidm.account(name, email).await {
|
||||
Ok(account) => {
|
||||
invited.username = account.username;
|
||||
invited.reset_link = account.reset_link;
|
||||
invited.link_expires_ms = account.link_expires_ms;
|
||||
invited.created = account.created;
|
||||
}
|
||||
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(invited)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a person out of a group. Through Kanidm only what portal
|
||||
/// put there (see `Invited.already_member`); memberships remember.
|
||||
pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> {
|
||||
match self {
|
||||
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
|
||||
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_kanidm(&self) -> bool {
|
||||
matches!(self, People::Kanidm(_))
|
||||
}
|
||||
}
|
||||
pub const INVITED_SUBJECT: &str = "portal.people.invited";
|
||||
/// The states an invite moves between, besides the schema's initial
|
||||
/// `open` (see `BUILTIN_AGGREGATES_YAML`).
|
||||
pub const INVITE_EXPIRED: &str = "expired";
|
||||
pub const INVITE_DONE: &str = "done";
|
||||
/// What an invite made at start is recorded under, where a desk invite
|
||||
/// has the name of the alternative that made it.
|
||||
pub const SEED_ALTERNATIVE: &str = "Invited at start";
|
||||
/// Stamped on a move nobody pressed, in place of a username.
|
||||
pub const BY: &str = "portal";
|
||||
|
||||
/// How long a link lives: the longest Kanidm allows, since it travels
|
||||
/// by email and is read when the person gets to it.
|
||||
const LINK_SECONDS: i64 = 86_400;
|
||||
|
||||
/// What an invite produced: the account, and the link that sets it up.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -29,6 +123,62 @@ pub struct Invited {
|
||||
pub reset_link: Option<String>,
|
||||
pub link_expires_ms: Option<i64>,
|
||||
pub created: bool,
|
||||
/// They were in the group before this invite put them there, so
|
||||
/// the membership is not portal's to take back.
|
||||
#[serde(default)]
|
||||
pub already_member: bool,
|
||||
}
|
||||
|
||||
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||
/// the epoch in 1.11, an RFC 3339 string before that.
|
||||
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's members as bare account names (`kari`, not
|
||||
/// `kari@id.example.no`). Kanidm answers an empty group with `null`.
|
||||
fn members_from(body: &str) -> Vec<String> {
|
||||
serde_json::from_str::<Option<Vec<String>>>(body)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| m.split('@').next().unwrap_or_default().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a `_credential/_status` answer shows a credential. Only a
|
||||
/// yes means anything: a person with nothing but a passkey is answered
|
||||
/// with an empty list too.
|
||||
fn has_credentials_from(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -60,63 +210,145 @@ 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?;
|
||||
// Refused is an answer, and the username decides. Anything else
|
||||
// that is not a yes is Kanidm not answering: guessing then could
|
||||
// give someone a second account.
|
||||
if (400..500).contains(&status) {
|
||||
tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
|
||||
return Ok(None);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: searching people by mail: http {status}");
|
||||
}
|
||||
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? {
|
||||
self.onboard(name, email, Some(group)).await
|
||||
}
|
||||
|
||||
/// An account and nothing more: for a person the content names as
|
||||
/// responsible for a page, who needs to be able to sign in whether
|
||||
/// or not they sit at any desk. `group` is empty in what comes back.
|
||||
pub async fn account(&self, name: &str, email: &str) -> anyhow::Result<Invited> {
|
||||
self.onboard(name, email, None).await
|
||||
}
|
||||
|
||||
/// Whether Kanidm already holds a person with this address.
|
||||
pub async fn has_person_with_mail(&self, email: &str) -> anyhow::Result<bool> {
|
||||
Ok(self.find_by_mail(email).await?.is_some())
|
||||
}
|
||||
|
||||
/// Who is in a group, by account name.
|
||||
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{group}/_attr/member"), None).await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
|
||||
}
|
||||
Ok(members_from(&text))
|
||||
}
|
||||
|
||||
/// Takes a person out of a group. Theirs to do for a group the
|
||||
/// service account manages, like adding them was.
|
||||
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether Kanidm shows a credential for this person. `false` is
|
||||
/// "not that it can see", never "none": see `has_credentials_from`.
|
||||
pub async fn has_credentials(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: credential status of {username}: http {status}");
|
||||
}
|
||||
Ok(has_credentials_from(&text))
|
||||
}
|
||||
|
||||
/// A one-time link where the person sets their own password or
|
||||
/// passkey, and when it runs out.
|
||||
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Intent {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
expiry_time: Option<serde_json::Value>,
|
||||
}
|
||||
let intent: Intent = serde_json::from_str(&text)?;
|
||||
Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms)))
|
||||
}
|
||||
|
||||
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
|
||||
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 {
|
||||
// The address goes in with the person, in one call: the
|
||||
// onboarding account may create a person but not modify one
|
||||
// (idm_people_on_boarding), so a separate PUT of `mail`
|
||||
// afterwards is refused with 403 and the invite fails.
|
||||
let (status, text) = self
|
||||
.call(
|
||||
openidconnect::reqwest::Method::POST,
|
||||
"/v1/person",
|
||||
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
|
||||
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name], "mail": [email] } })),
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
|
||||
}
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
|
||||
}
|
||||
true
|
||||
};
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
||||
}
|
||||
let (reset_link, link_expires_ms) = if created {
|
||||
// 24 hours: the longest Kanidm allows, since the link travels
|
||||
// by email and is read when the person gets to it.
|
||||
let mut already_member = false;
|
||||
if let Some(group) = group {
|
||||
// Someone new is in no group. For anyone else, whether they
|
||||
// were here before is what says whose the membership is; a
|
||||
// failed read says "not before", which errs toward portal
|
||||
// tidying up after itself.
|
||||
already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None)
|
||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Intent {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
expiry_time: Option<serde_json::Value>,
|
||||
}
|
||||
let intent: Intent = serde_json::from_str(&text)?;
|
||||
let expires = intent.expiry_time.as_ref().and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t| t.timestamp_millis());
|
||||
(Some(format!("{}/ui/reset?token={}", self.base, intent.token)), expires)
|
||||
}
|
||||
let (reset_link, link_expires_ms) = if created {
|
||||
let (link, expires) = self.reset_link(&username).await?;
|
||||
(Some(link), expires)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -124,14 +356,221 @@ impl Kanidm {
|
||||
username,
|
||||
display_name: name.to_string(),
|
||||
email: email.to_string(),
|
||||
group: group.to_string(),
|
||||
group: group.unwrap_or_default().to_string(),
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// When an open invite's link runs out: what Kanidm said, or a link's
|
||||
/// lifetime after it was last sent for a record that does not say.
|
||||
fn link_runs_out(answer: &crate::answers::Answer) -> i64 {
|
||||
answer
|
||||
.responses
|
||||
.get("invited")
|
||||
.and_then(|i| i.get("link_expires_ms"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_else(|| answer.decided_ms.unwrap_or(answer.submitted_ms) + LINK_SECONDS * 1000)
|
||||
}
|
||||
|
||||
/// The account an invite record is about.
|
||||
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
||||
answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// Where a waiting invite goes now, if anywhere. Pure: what is known
|
||||
/// about the person comes in as `is_in`.
|
||||
///
|
||||
/// An invite that made no account has no link to wait for: the person
|
||||
/// had their way in already, and was only given a place.
|
||||
pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> {
|
||||
if answer.state != crate::answers::OPEN_STATE {
|
||||
return None;
|
||||
}
|
||||
let made_an_account = invited_of(answer).is_some_and(|i| i.created);
|
||||
if is_in || !made_an_account {
|
||||
Some(INVITE_DONE)
|
||||
} else if now_ms >= link_runs_out(answer) {
|
||||
Some(INVITE_EXPIRED)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An invite nobody at a desk made - the first person, someone the
|
||||
/// content names as responsible - on file like any other, so the desk
|
||||
/// that reads `portal_invites` sees it, and can send it again.
|
||||
pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mut responses = serde_json::json!({
|
||||
"name": invited.display_name,
|
||||
"email": invited.email,
|
||||
"invited": invited,
|
||||
});
|
||||
if !responsible_for.is_empty() {
|
||||
responses["responsible_for"] = serde_json::json!(responsible_for);
|
||||
}
|
||||
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
|
||||
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).await {
|
||||
tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an invite record, the way a desk button would: through the
|
||||
/// event log, onto the projection, and out on the answers subject.
|
||||
async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) {
|
||||
let aggregates = state.aggregates.load();
|
||||
let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else {
|
||||
// A content repo may declare the bucket itself, without this
|
||||
// state. Then the invite stays where its own graph has it.
|
||||
return;
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY });
|
||||
if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await {
|
||||
tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}");
|
||||
return;
|
||||
}
|
||||
answer.state = target.to_string();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(BY.to_string());
|
||||
// The link is a way in for as long as it is on file; one that is
|
||||
// used or run out is only something to leak.
|
||||
if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) {
|
||||
invited.insert("reset_link".into(), serde_json::Value::Null);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&answer.id, data.into()).await;
|
||||
}
|
||||
let parent_hashes = vec![answer.id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let event = crate::events::AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id: answer.question_id.clone(),
|
||||
alternative: target.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
if let Err(e) = crate::events::emit_answer_submitted(&state.nats, &event).await {
|
||||
tracing::warn!(id = %answer.id, target, "the invite was moved but not published: {e}");
|
||||
}
|
||||
tracing::info!(id = %answer.id, target, "an invite moved on its own");
|
||||
}
|
||||
|
||||
/// Every invite on file that is still waiting in one of `states`.
|
||||
async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec<crate::answers::Answer>)> {
|
||||
// Made on first write: a site that has invited nobody has no bucket.
|
||||
let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?;
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
store.keys().await.ok()?.try_collect().await.unwrap_or_default()
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
if states.contains(&answer.state.as_str()) {
|
||||
found.push(answer);
|
||||
}
|
||||
}
|
||||
Some((store, found))
|
||||
}
|
||||
|
||||
/// Runs for the life of the process, every ten minutes: a link lives a
|
||||
/// day, so an invite is never shown as waiting long after it stopped.
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(10 * 60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
sweep_invites(&state).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass over the waiting invites: done for whoever Kanidm shows a
|
||||
/// credential for, expired for a link that ran out.
|
||||
pub async fn sweep_invites(state: &crate::server::AppState) {
|
||||
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return };
|
||||
let kanidm = Kanidm::from_env();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
for answer in waiting {
|
||||
let is_in = match (&kanidm, invited_of(&answer)) {
|
||||
(Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(target) = invite_due(&answer, is_in, now_ms) {
|
||||
move_invite(state, &store, answer, target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an invite was this person's: the account it made, or the
|
||||
/// address it went to for someone whose account goes by another name.
|
||||
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
|
||||
let address = invited.email.trim().to_lowercase();
|
||||
invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address))
|
||||
}
|
||||
|
||||
/// Someone signed in: whatever invites were waiting for them, or had
|
||||
/// run out, are done. This is the one sure sign a link was used -
|
||||
/// they are here.
|
||||
pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) {
|
||||
let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return };
|
||||
for answer in waiting {
|
||||
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
|
||||
move_invite(&state, &store, answer, INVITE_DONE).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A desk sent an expired invite again (`expired` to `open`): a fresh
|
||||
/// link, on the record and in the mail. The same mail as the first
|
||||
/// time, which for someone the content names as responsible is the one
|
||||
/// that says so.
|
||||
pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
||||
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { return };
|
||||
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { return };
|
||||
let Some(mut invited) = invited_of(&answer) else {
|
||||
tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent");
|
||||
return;
|
||||
};
|
||||
let Some(kanidm) = Kanidm::from_env() else {
|
||||
tracing::warn!(id, "an invite was sent again, but invites are not configured on this instance (KANIDM_API_TOKEN) - nothing sent");
|
||||
return;
|
||||
};
|
||||
let (link, expires) = match kanidm.reset_link(&invited.username).await {
|
||||
Ok(made) => made,
|
||||
Err(e) => {
|
||||
tracing::error!(id, user = %invited.username, "a new link could not be made: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
invited.reset_link = Some(link);
|
||||
invited.link_expires_ms = expires;
|
||||
if let Some(map) = answer.responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(id, data.into()).await;
|
||||
}
|
||||
let responsible_for: Vec<String> = answer
|
||||
.responses
|
||||
.get("responsible_for")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
if responsible_for.is_empty() {
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
} else {
|
||||
crate::mail::on_responsible(state, &invited, &responsible_for).await;
|
||||
}
|
||||
tracing::info!(id, user = %invited.username, "an invite was sent again");
|
||||
}
|
||||
|
||||
/// A Kanidm account name from an email: the local part, lowercased,
|
||||
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
|
||||
pub fn username_for(email: &str) -> String {
|
||||
@@ -150,6 +589,68 @@ pub fn username_for(email: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The person a record is about, for a state that grants a group: their
|
||||
/// name, and a phone number or an address to know them by. Required by
|
||||
/// the lint of any form that fills such a bucket (see
|
||||
/// `validate_questions`), so a record missing them is one that predates
|
||||
/// the rule, and is reported rather than guessed at.
|
||||
pub fn who_for(responses: &serde_json::Value) -> Option<(String, crate::memberships::Identity)> {
|
||||
let name = responses
|
||||
.get("name")
|
||||
.or_else(|| responses.get("navn"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)?;
|
||||
let who = crate::memberships::Identity::from_responses(responses);
|
||||
who.key().is_some().then_some((name, who))
|
||||
}
|
||||
|
||||
/// A record entered `state`: if that state grants a group, put the
|
||||
/// person the record is about in it. The decision the desk already
|
||||
/// pressed is the whole ceremony - an approval that means membership
|
||||
/// should not also need somebody to remember to invite them.
|
||||
///
|
||||
/// Best-effort, exactly like the mail beside it: the transition is
|
||||
/// committed either way, and an instance with no Kanidm configured
|
||||
/// simply grants nothing and says so once per occurrence.
|
||||
pub async fn on_state(
|
||||
state: &crate::server::AppState,
|
||||
bucket: &str,
|
||||
id: &str,
|
||||
to_state: &str,
|
||||
responses: &serde_json::Value,
|
||||
) {
|
||||
let Some(group) = state
|
||||
.aggregates
|
||||
.load()
|
||||
.get(bucket)
|
||||
.and_then(|s| s.grants_for(to_state).map(str::to_string))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(people) = People::from_env() else {
|
||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED);
|
||||
return;
|
||||
};
|
||||
let Some((name, who)) = who_for(responses) else {
|
||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added");
|
||||
return;
|
||||
};
|
||||
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await {
|
||||
Ok(invited) => invited,
|
||||
Err(e) => {
|
||||
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(INVITED_SUBJECT, bytes.into()).await;
|
||||
}
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
tracing::info!(bucket, id, to_state, group, user = %invited.username, created = invited.created, "the decision granted a group");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -161,4 +662,96 @@ 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_link_runs_out_when_kanidm_says_in_either_spelling() {
|
||||
assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!(null)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_are_account_names_and_an_empty_group_is_null() {
|
||||
assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]);
|
||||
assert!(members_from("null").is_empty());
|
||||
assert!(members_from("nonsense").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_listed_credential_counts() {
|
||||
assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#));
|
||||
assert!(!has_credentials_from(r#"{"creds":[]}"#));
|
||||
assert!(!has_credentials_from("null"));
|
||||
}
|
||||
|
||||
fn invite(state: &str, created: bool, expires: Option<i64>) -> crate::answers::Answer {
|
||||
crate::answers::Answer {
|
||||
id: "a".into(),
|
||||
question_id: "/styret".into(),
|
||||
alternative: "Inviter".into(),
|
||||
responses: serde_json::json!({ "name": "Kari", "email": "kari@vel.no", "invited": {
|
||||
"username": "kari", "display_name": "Kari", "email": "kari@vel.no", "group": "styret",
|
||||
"reset_link": "https://id/ui/reset?token=x", "link_expires_ms": expires, "created": created,
|
||||
}}),
|
||||
state: state.into(),
|
||||
submitted_ms: 1_000,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
|
||||
let day = LINK_SECONDS * 1000;
|
||||
// Still within the link's life, and nobody has seen them: wait.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None);
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED));
|
||||
// In is in, however long the link had left.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE));
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE));
|
||||
// A record from before links carried their end: a day from when it was sent.
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None);
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED));
|
||||
// Someone who had an account was only given a place: nothing to wait for.
|
||||
assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE));
|
||||
// Only a waiting invite moves.
|
||||
assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None);
|
||||
assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invite_is_theirs_by_account_or_by_address() {
|
||||
let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap();
|
||||
let user = |username: &str, email: Option<&str>| crate::auth::User {
|
||||
sub: "s".into(),
|
||||
username: username.into(),
|
||||
display_name: String::new(),
|
||||
groups: vec![],
|
||||
phone: None,
|
||||
email: email.map(String::from),
|
||||
};
|
||||
assert!(is_for(&invited, &user("kari", None)));
|
||||
assert!(is_for(&invited, &user("kari.n", Some("Kari@Vel.no"))));
|
||||
assert!(!is_for(&invited, &user("ola", Some("ola@vel.no"))));
|
||||
assert!(!is_for(&invited, &user("ola", 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! What the storylines did, as numbers and nothing else.
|
||||
//!
|
||||
//! `GET /report` answers with a count per bucket, per storyline, per
|
||||
//! state: how many cases each storyline brought in and where they got
|
||||
//! to. That is the whole of what the storylines are compared on. No
|
||||
//! record, no field, no address, no time leaves the site: a storyline
|
||||
//! is assigned evenly, so the counts are the comparison, and the
|
||||
//! trainer (`portal-trainer report`) reads them to say which words
|
||||
//! worked. Public, since a count is what a public list already shows.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The report: the site's storylines, and per bucket the count of
|
||||
/// records by storyline and state.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub storylines: usize,
|
||||
/// bucket -> storyline (as a string, JSON's way) -> state -> count
|
||||
pub buckets: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
||||
}
|
||||
|
||||
pub async fn gather(state: &crate::server::AppState) -> Report {
|
||||
let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() };
|
||||
let aggregates = state.aggregates.load();
|
||||
for bucket in aggregates.keys() {
|
||||
// The runtime's own buckets are not the site's offers.
|
||||
if bucket.starts_with("portal_") {
|
||||
continue;
|
||||
}
|
||||
let Ok(store) = state.jetstream.get_key_value(bucket).await else { continue };
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
let counts = report.buckets.entry(bucket.clone()).or_default();
|
||||
for key in keys {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
*counts.entry(answer.storyline.to_string()).or_default().entry(answer.state).or_default() += 1;
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
pub async fn handler(axum::extract::State(state): axum::extract::State<crate::server::AppState>) -> axum::Json<Report> {
|
||||
axum::Json(gather(&state).await)
|
||||
}
|
||||
+113
-10
@@ -13,7 +13,7 @@ use leptos::prelude::*;
|
||||
/// `public: true` nor `requires_group` set is unreachable, not "open"
|
||||
/// by omission. `params` are the requesting form's current field
|
||||
/// values (if any) - forwarded as query parameters to a live external
|
||||
/// fetch (`GiteaStarred`/`GiteaOrgRepos`/`Url`), the mechanism behind
|
||||
/// fetch (`GiteaStarred`/`GiteaReleases`/`Url`), the mechanism behind
|
||||
/// "a resource parameterized by other form fields"; a `Kv` resource
|
||||
/// ignores them entirely, same as today.
|
||||
#[server(endpoint = "get_resource")]
|
||||
@@ -33,7 +33,7 @@ pub async fn get_resource(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let resource = feature
|
||||
.resource
|
||||
.as_ref()
|
||||
@@ -62,7 +62,7 @@ pub async fn get_requirement_options(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -103,7 +103,7 @@ pub async fn get_requirement_binding(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -128,6 +128,7 @@ pub async fn get_requirement_binding(
|
||||
#[cfg(feature = "ssr")]
|
||||
fn find_feature(
|
||||
state: &crate::server::AppState,
|
||||
storyline: usize,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
feature_name: &str,
|
||||
@@ -137,7 +138,7 @@ fn find_feature(
|
||||
// also what substitutes the URL segment into the page's resource
|
||||
// keys - so this lookup is where a `/review/<record>` page's
|
||||
// feature acquires its record-specific key.
|
||||
let question = crate::content::resolve_question(&state.questions.load(), question_id)
|
||||
let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id)
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
question
|
||||
.alternatives
|
||||
@@ -188,6 +189,50 @@ async fn authorize_resource(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stored record passes the resource's `states` filter. No
|
||||
/// filter shows everything, which is what every resource written
|
||||
/// before this existed means. A record with no `state` at all is not
|
||||
/// an aggregate and is left alone.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Value) -> bool {
|
||||
if resource.states.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match value.get("state").and_then(|s| s.as_str()) {
|
||||
Some(state) => resource.states.iter().any(|s| s == state),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stored record passes the resource's `where` filter. The
|
||||
/// field is looked for in the record's `responses`, which is where a
|
||||
/// form's answers live, and then on the record itself, so a listing
|
||||
/// can be narrowed by what was submitted or by what the runtime
|
||||
/// stamped on it. A missing field never matches: "everything that did
|
||||
/// not say" is not something a page should be able to ask for by
|
||||
/// accident.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn matches_filter(
|
||||
resource: &crate::content::ResourceSpec,
|
||||
value: &serde_json::Value,
|
||||
params: &std::collections::HashMap<String, String>,
|
||||
) -> bool {
|
||||
let Some(filter) = &resource.filter else {
|
||||
return true;
|
||||
};
|
||||
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
|
||||
.unwrap_or_else(|| filter.equals.clone());
|
||||
let found = value
|
||||
.get("responses")
|
||||
.and_then(|r| r.get(&filter.field))
|
||||
.or_else(|| value.get(&filter.field));
|
||||
match found {
|
||||
Some(serde_json::Value::String(s)) => s == &wanted,
|
||||
Some(other) => other.to_string() == wanted,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn fetch_resource_value(
|
||||
state: &crate::server::AppState,
|
||||
@@ -216,7 +261,14 @@ async fn fetch_resource_value(
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
|
||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
// A state filter holds for one record as well as a
|
||||
// listing, or naming the key would walk around it.
|
||||
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
|
||||
return Err(ServerFnError::new("resource key not found"));
|
||||
}
|
||||
value
|
||||
}
|
||||
(None, Some(store)) => {
|
||||
use futures::TryStreamExt;
|
||||
@@ -231,7 +283,9 @@ async fn fetch_resource_value(
|
||||
for key in keys {
|
||||
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
items.push(value);
|
||||
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
|
||||
items.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,9 +296,6 @@ async fn fetch_resource_value(
|
||||
ResourceSource::GiteaStarred { username } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
||||
}
|
||||
ResourceSource::GiteaOrgRepos { org } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
|
||||
}
|
||||
ResourceSource::GiteaReleases { owner, repo } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
|
||||
.await?
|
||||
@@ -470,6 +521,58 @@ pub fn apply_jq(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spec(yaml: &str) -> crate::content::ResourceSpec {
|
||||
serde_yaml::from_str(yaml).unwrap()
|
||||
}
|
||||
|
||||
fn record(state: &str, responses: serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({ "id": "r", "state": state, "responses": responses })
|
||||
}
|
||||
|
||||
// A register that is public by law is public about what was
|
||||
// decided, not about what was withdrawn.
|
||||
#[test]
|
||||
fn a_listing_can_be_narrowed_to_the_states_worth_showing() {
|
||||
let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n");
|
||||
let decided = spec("source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n");
|
||||
for state in ["approved", "refused", "withdrawn", "open"] {
|
||||
assert!(shows_state(&all, &record(state, serde_json::json!({}))));
|
||||
}
|
||||
assert!(shows_state(&decided, &record("approved", serde_json::json!({}))));
|
||||
assert!(shows_state(&decided, &record("refused", serde_json::json!({}))));
|
||||
assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({}))));
|
||||
// Something that is not an aggregate has no state to filter on
|
||||
// and is left alone.
|
||||
assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" })));
|
||||
}
|
||||
|
||||
// The other half of a record belonging to a record: the
|
||||
// application's page asking for the objections that name it.
|
||||
#[test]
|
||||
fn a_listing_can_be_narrowed_to_the_children_of_one_record() {
|
||||
let children = spec("source: { kind: kv, bucket: objections }\nrequires_group: case_officers\nwhere: { field: case, equals: \"{key}\" }\n");
|
||||
let params = std::collections::HashMap::from([("key".to_string(), "abc123".to_string())]);
|
||||
let mine = record("open", serde_json::json!({ "case": "abc123", "your_objection": "the sun" }));
|
||||
let theirs = record("open", serde_json::json!({ "case": "def456" }));
|
||||
let neither = record("open", serde_json::json!({ "your_objection": "no case named" }));
|
||||
assert!(matches_filter(&children, &mine, ¶ms));
|
||||
assert!(!matches_filter(&children, &theirs, ¶ms));
|
||||
// A record that did not say is not everyone's child.
|
||||
assert!(!matches_filter(&children, &neither, ¶ms));
|
||||
// No filter, no narrowing: every resource written before this
|
||||
// existed still lists its whole bucket.
|
||||
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
||||
assert!(matches_filter(&plain, &theirs, ¶ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_can_name_a_value_outright() {
|
||||
let fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n");
|
||||
let params = std::collections::HashMap::new();
|
||||
assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), ¶ms));
|
||||
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
||||
}
|
||||
|
||||
/// A canned, Gitea-API-shaped fixture - the same fields the real
|
||||
/// `/users/{username}/starred` endpoint returns (Gitea 1.27:
|
||||
/// `stars_count`, not GitHub's `stargazers_count`; `website` is ""
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
//! Accounts the content mandates, made at start.
|
||||
//!
|
||||
//! **The first person in.** A site with its own Kanidm starts with nobody
|
||||
//! on any desk, and every later person is invited from a desk - so the
|
||||
//! first one has to come from somewhere else. `SEED_ADMIN_EMAIL` names
|
||||
//! them: at start, portal invites that address into the site's most
|
||||
//! privileged group and mails them the one-time link, exactly as a desk
|
||||
//! invite would. From there they invite everyone else themselves.
|
||||
//!
|
||||
//! - `SEED_ADMIN_EMAIL` - who; unset, nothing happens.
|
||||
//! - `SEED_ADMIN_NAME` - their display name; defaults to the address's
|
||||
//! local part.
|
||||
//! - `SEED_GROUP` - the group; defaults to the one whose desk may invite
|
||||
//! into the most groups (the board, on a vel), read from the content.
|
||||
//!
|
||||
//! Once, not on every start: a done seed is remembered in the
|
||||
//! `portal_seed` bucket, keyed by address and group, so a restart or an
|
||||
//! upgrade never mails a second link. Changing `SEED_ADMIN_EMAIL` or
|
||||
//! `SEED_GROUP` seeds the new pair.
|
||||
//!
|
||||
//! **Everyone the content names as responsible.** A page's
|
||||
//! `responsible: { name, contact }` is a person answering for that
|
||||
//! question - unless `group` says it is a group's, in which case the
|
||||
//! address is an inbox and nobody is made for it. A person gets an
|
||||
//! account: at start, and again whenever the
|
||||
//! content changes, each responsible address Kanidm does not already
|
||||
//! hold is given one, and mailed that they are listed as responsible
|
||||
//! for asking those questions, with the link to sign in. Signed in,
|
||||
//! their own pages offer them a way to suggest changes.
|
||||
//! `RESPONSIBLE_GROUP` names a group they all join - on the vel,
|
||||
//! `redaktor`, which is also who may sign in to the project Gitea - and
|
||||
//! people who already had an account are added to it without a mail.
|
||||
//! A desk is still a desk invite. Each address is onboarded once per
|
||||
//! group (`portal_seed`).
|
||||
//!
|
||||
//! **And no longer.** Someone the content has stopped naming leaves
|
||||
//! `RESPONSIBLE_GROUP` in the same pass, if portal is who put them
|
||||
//! there. Their account is theirs and stays.
|
||||
//!
|
||||
//! Both run side by side and try again a minute apart, twenty times:
|
||||
//! Kanidm and gdo may still be coming up alongside portal. Every
|
||||
//! invite made here is put on file in `portal_invites` like a desk's,
|
||||
//! so a link that runs out unused shows up where someone can send it
|
||||
//! again.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use crate::content::Question;
|
||||
|
||||
pub const SEED_BUCKET: &str = "portal_seed";
|
||||
|
||||
/// The group whose members may invite into the most groups: a page
|
||||
/// gated to group G carrying an `invite: { group: X }` alternative
|
||||
/// means G may bring people into X. Ties go to the name that sorts
|
||||
/// first, so the choice is stable across starts. `None` when no page
|
||||
/// invites anyone.
|
||||
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
|
||||
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for q in questions.values() {
|
||||
let Some(group) = q.qualifies.as_deref() else { continue };
|
||||
for alt in &q.alternatives {
|
||||
if let Some(invite) = &alt.invite {
|
||||
reach.entry(group).or_default().insert(invite.group.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
reach
|
||||
.iter()
|
||||
.max_by(|(a, x), (b, y)| x.len().cmp(&y.len()).then_with(|| b.cmp(a)))
|
||||
.map(|(group, _)| group.to_string())
|
||||
}
|
||||
|
||||
/// The bucket key for one seed: stable, and within NATS key rules.
|
||||
pub fn seed_key(email: &str, group: &str) -> String {
|
||||
let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();
|
||||
format!("{}.{}", clean(group), clean(email))
|
||||
}
|
||||
|
||||
/// Everyone the content names as responsible, by address (lowercased,
|
||||
/// `mailto:` taken off): their name as the first page gives it, and the
|
||||
/// questions they answer for, in page order. A page a group answers
|
||||
/// for (`responsible.group`) names nobody: the board's inbox is not a
|
||||
/// person, and its members have their accounts through the desk.
|
||||
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
|
||||
let mut ids: Vec<&String> = questions.keys().collect();
|
||||
ids.sort();
|
||||
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
let q = &questions[id];
|
||||
let Some(r) = &q.responsible else { continue };
|
||||
if r.group.as_deref().is_some_and(|g| !g.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let email = contact_email(&r.contact);
|
||||
let Some(email) = email else { continue };
|
||||
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
||||
if !entry.1.contains(&q.name) {
|
||||
entry.1.push(q.name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A responsible `contact` as an address: `mailto:` off, lowercased;
|
||||
/// `None` for a phone number or anything else that is not one.
|
||||
pub fn contact_email(contact: &str) -> Option<String> {
|
||||
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
||||
}
|
||||
|
||||
/// A failed attempt is tried again this many times, this far apart:
|
||||
/// Kanidm and gdo may still be coming up beside portal.
|
||||
const ATTEMPTS: u32 = 20;
|
||||
const APART: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// One pass over the responsible people at a time: a reload may arrive
|
||||
/// while the pass from the start is still at it, and two at once would
|
||||
/// both find the same person missing and invite them twice.
|
||||
static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// The first person in and everyone the content names as responsible,
|
||||
/// side by side, so neither waits on the other's retries - and the
|
||||
/// responsible again whenever the content changes, for the life of the
|
||||
/// process.
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
// Subscribed before the first pass, so content that changes while
|
||||
// it runs is not missed.
|
||||
let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await;
|
||||
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
|
||||
let mut reloads = match reloads {
|
||||
Ok(reloads) => reloads,
|
||||
Err(e) => {
|
||||
tracing::error!("could not listen for changed content: {e} - a new responsible person waits for the next start");
|
||||
return;
|
||||
}
|
||||
};
|
||||
use futures::StreamExt;
|
||||
while reloads.next().await.is_some() {
|
||||
responsible_until_done(&state).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Passes over the responsible people until one leaves nobody behind.
|
||||
/// Each pass reads the content afresh, so what it retries is what the
|
||||
/// content says by then.
|
||||
async fn responsible_until_done(state: &crate::server::AppState) {
|
||||
for attempt in 1..=ATTEMPTS {
|
||||
let left = {
|
||||
let _one_at_a_time = PASS.lock().await;
|
||||
responsible(state).await
|
||||
};
|
||||
if left == 0 {
|
||||
return;
|
||||
}
|
||||
if attempt == ATTEMPTS {
|
||||
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
|
||||
return;
|
||||
}
|
||||
tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute");
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
||||
match state.jetstream.get_key_value(SEED_BUCKET).await {
|
||||
Ok(store) => Some(store),
|
||||
Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
|
||||
Ok(store) => Some(store),
|
||||
Err(e) => {
|
||||
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_admin_email() -> Option<String> {
|
||||
std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@'))
|
||||
}
|
||||
|
||||
/// Who portal put in the responsible group and the content no longer
|
||||
/// names: their key in the seed bucket, and the account. The first
|
||||
/// person is never among them - with `SEED_GROUP` the same group, their
|
||||
/// place there is from the seed, not from a page.
|
||||
pub fn no_longer_responsible(
|
||||
on_file: &[(String, crate::people::Invited)],
|
||||
people: &BTreeMap<String, (String, Vec<String>)>,
|
||||
seed_admin: Option<&str>,
|
||||
) -> Vec<(String, crate::people::Invited)> {
|
||||
on_file
|
||||
.iter()
|
||||
.filter(|(_, invited)| {
|
||||
let email = invited.email.trim().to_lowercase();
|
||||
!people.contains_key(&email) && seed_admin != Some(email.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One pass: an account for everyone the content names, and out of the
|
||||
/// group for everyone it has stopped naming. Returns how many people
|
||||
/// it could not finish with.
|
||||
async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
let people = responsible_people(&state.questions.load());
|
||||
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
|
||||
if people.is_empty() && group.is_none() {
|
||||
return 0;
|
||||
}
|
||||
let Some(people_backend) = crate::people::People::from_env() else {
|
||||
if !people.is_empty() {
|
||||
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
let Some(store) = seed_bucket(state).await else { return 0 };
|
||||
let mut left = 0;
|
||||
for (email, (name, questions)) in &people {
|
||||
let key = seed_key(email, group.as_deref().unwrap_or("responsible"));
|
||||
if let Ok(Some(_)) = store.get(&key).await {
|
||||
continue;
|
||||
}
|
||||
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
|
||||
let who = crate::memberships::Identity::from_contact(email);
|
||||
// With a group, one call makes the account if there is none and
|
||||
// adds them either way; without one, only a missing account is
|
||||
// made (Kanidm; a membership is nothing without a group). Either
|
||||
// way a person is mailed only when an account was made for
|
||||
// them: someone who had one already has their way in.
|
||||
let made = match (&group, &people_backend) {
|
||||
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
|
||||
(None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await {
|
||||
Ok(true) => {
|
||||
let _ = store.put(&key, "existing".into()).await;
|
||||
continue;
|
||||
}
|
||||
Ok(false) => kanidm.account(&name, email).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
(None, crate::people::People::Memberships) => continue,
|
||||
};
|
||||
match made {
|
||||
Ok(invited) => {
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||
let _ = store.put(&key, bytes.into()).await;
|
||||
}
|
||||
if invited.created {
|
||||
crate::people::record(state, &invited, questions).await;
|
||||
crate::mail::on_responsible(state, &invited, questions).await;
|
||||
}
|
||||
tracing::info!(user = %invited.username, created = invited.created, group = group.as_deref().unwrap_or(""), "a person the content names as responsible has their account");
|
||||
}
|
||||
Err(e) => {
|
||||
left += 1;
|
||||
tracing::warn!(%email, "could not set up a responsible person: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Being responsible ends when the content stops saying so. The
|
||||
// account stays - it is theirs - but the place in the group, and
|
||||
// whatever else that group opens, goes with the page. Only what
|
||||
// portal gave: someone who was in the group before is left there.
|
||||
let Some(group) = group else { return left };
|
||||
let prefix = seed_key("", &group);
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut on_file = Vec::new();
|
||||
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else { continue };
|
||||
on_file.push((key, invited));
|
||||
}
|
||||
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
|
||||
if !invited.already_member {
|
||||
let who = crate::memberships::Identity::from_contact(&invited.email);
|
||||
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await {
|
||||
left += 1;
|
||||
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Forgotten, so that being named again is being set up again.
|
||||
let _ = store.purge(&key).await;
|
||||
tracing::info!(user = %invited.username, group, removed = !invited.already_member, "the content no longer names this person as responsible");
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one -
|
||||
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
|
||||
/// their number.
|
||||
async fn seed_admin(state: &crate::server::AppState) {
|
||||
let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
|
||||
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
|
||||
let (Some(contact), who) = (
|
||||
phone.clone().or_else(|| email.clone()),
|
||||
crate::memberships::Identity {
|
||||
sub: None,
|
||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||
email: email.as_deref().map(|e| e.to_lowercase()),
|
||||
},
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let email = contact;
|
||||
let name = std::env::var("SEED_ADMIN_NAME")
|
||||
.ok()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
|
||||
let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) {
|
||||
Some(g) => g,
|
||||
None => match most_privileged_group(&state.questions.load()) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
tracing::warn!("SEED_ADMIN_EMAIL is set, but no page invites anyone and SEED_GROUP is unset - nobody to seed into");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let key = seed_key(&email, &group);
|
||||
let Some(store) = seed_bucket(state).await else { return };
|
||||
if let Ok(Some(_)) = store.get(&key).await {
|
||||
tracing::info!(group, "the first person was already invited into this group; not again");
|
||||
return;
|
||||
}
|
||||
let Some(people) = crate::people::People::from_env() else {
|
||||
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut attempt = 0;
|
||||
let invited = loop {
|
||||
attempt += 1;
|
||||
match people.invite(state, &name, &who, &group, "seed").await {
|
||||
Ok(invited) => break invited,
|
||||
Err(e) if attempt < ATTEMPTS => {
|
||||
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(group, "seeding the first person failed for good: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||
if let Err(e) = store.put(&key, bytes.into()).await {
|
||||
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
|
||||
}
|
||||
}
|
||||
if invited.created {
|
||||
crate::people::record(state, &invited, &[]).await;
|
||||
}
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
tracing::info!(group, user = %invited.username, created = invited.created, "the first person is invited");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn page(id: &str, qualifies: Option<&str>, invites: &[&str]) -> (String, Question) {
|
||||
let alternatives: String = invites
|
||||
.iter()
|
||||
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
|
||||
.collect();
|
||||
let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives });
|
||||
let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
|
||||
q.id = id.to_string();
|
||||
(id.to_string(), q)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
|
||||
let qs: HashMap<String, Question> = [
|
||||
page("/", None, &[]),
|
||||
page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
|
||||
page("/kasserer", Some("kasserer"), &["kasserer"]),
|
||||
page("/trafikk", Some("trafikk"), &["trafikk"]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
|
||||
// A tie goes to the name that sorts first, every time.
|
||||
let tie: HashMap<String, Question> = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect();
|
||||
assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
|
||||
// No invitations anywhere: nobody to seed into without SEED_GROUP.
|
||||
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
|
||||
assert!(most_privileged_group(&none).is_none());
|
||||
}
|
||||
|
||||
fn on_file(email: &str, already_member: bool) -> (String, crate::people::Invited) {
|
||||
(
|
||||
seed_key(email, "redaktor"),
|
||||
crate::people::Invited {
|
||||
username: email.split('@').next().unwrap().to_string(),
|
||||
display_name: String::new(),
|
||||
email: email.to_string(),
|
||||
group: "redaktor".to_string(),
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() {
|
||||
let people: BTreeMap<String, (String, Vec<String>)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect();
|
||||
let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)];
|
||||
let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no"));
|
||||
assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::<Vec<_>>(), vec!["Ola"]);
|
||||
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
|
||||
// Nobody named at all: everyone portal put there leaves.
|
||||
assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_named_responsible_is_found_once_by_their_address() {
|
||||
let mut qs: HashMap<String, Question> = HashMap::new();
|
||||
for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] {
|
||||
let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap();
|
||||
q.id = id.to_string();
|
||||
qs.insert(id.to_string(), q);
|
||||
}
|
||||
let people = responsible_people(&qs);
|
||||
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
|
||||
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
|
||||
assert_eq!(contact_email("+47 900 00 000"), None);
|
||||
// A group's inbox is nobody: its members have the desk.
|
||||
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
|
||||
held.id = "/d".into();
|
||||
qs.insert("/d".into(), held);
|
||||
assert!(!responsible_people(&qs).contains_key("post@vel.no"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_is_remembered_by_address_and_group() {
|
||||
assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
|
||||
assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
pub mod oidc;
|
||||
pub mod storyline;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::extract::FromRef;
|
||||
@@ -21,18 +22,18 @@ pub struct AppState {
|
||||
/// message (see `content::watch_for_reload`) - readers never hold
|
||||
/// a lock, just an atomic pointer load, so a reload never blocks or
|
||||
/// is blocked by an in-flight request.
|
||||
pub questions: Arc<ArcSwap<HashMap<String, Question>>>,
|
||||
pub questions: Arc<ArcSwap<crate::content::Storylines<HashMap<String, Question>>>>,
|
||||
/// Every bucket's event-sourced state graph, loaded from
|
||||
/// `aggregates.yaml` (see `content::load_aggregates_from_gitea`) and
|
||||
/// hot-swapped alongside `questions` on the same reload. A bucket
|
||||
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
||||
/// still works (see `answers.rs`).
|
||||
pub aggregates: Arc<ArcSwap<HashMap<String, crate::aggregates::AggregateSchema>>>,
|
||||
pub aggregates: Arc<ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>>,
|
||||
/// Site branding from the content repo's optional `site.yaml`
|
||||
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
||||
/// `aggregates` on the same reload. Default = the historical uhhm
|
||||
/// look.
|
||||
pub site: Arc<ArcSwap<crate::content::SiteConfig>>,
|
||||
pub site: Arc<ArcSwap<crate::content::Storylines<crate::content::SiteConfig>>>,
|
||||
/// The access policy compiled from the content above (see
|
||||
/// `access.rs`), swapped with it on every reload. Every server entry
|
||||
/// point decides through this and nothing else.
|
||||
|
||||
+44
-6
@@ -11,10 +11,10 @@ use axum::{
|
||||
response::Redirect,
|
||||
};
|
||||
use openidconnect::{
|
||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
|
||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||
TokenResponse,
|
||||
OAuth2TokenResponse, TokenResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
@@ -50,6 +50,12 @@ const NONCE_KEY: &str = "oidc_nonce";
|
||||
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
||||
|
||||
impl Oidc {
|
||||
/// Sign-in disabled: what a content-only instance has, and what a
|
||||
/// test builds an `AppState` with.
|
||||
pub fn disabled() -> Self {
|
||||
Self { inner: None }
|
||||
}
|
||||
|
||||
/// Discovers the provider and builds the client from environment:
|
||||
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
|
||||
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
|
||||
@@ -143,7 +149,7 @@ pub async fn login(
|
||||
let oidc = state.oidc.configured()?;
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (auth_url, csrf_state, nonce) = oidc
|
||||
let mut request = oidc
|
||||
.client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
@@ -153,8 +159,14 @@ pub async fn login(
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
.set_pkce_challenge(pkce_challenge);
|
||||
// What else to ask the provider for: `phoneNumber` for one that
|
||||
// verifies numbers (Vipps). Only when asked, because Kanidm denies
|
||||
// a sign-in that requests a scope the person does not hold.
|
||||
for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() {
|
||||
request = request.add_scope(Scope::new(scope.to_string()));
|
||||
}
|
||||
let (auth_url, csrf_state, nonce) = request.url();
|
||||
|
||||
session
|
||||
.insert(PKCE_KEY, pkce_verifier.secret())
|
||||
@@ -241,12 +253,34 @@ pub async fn callback(
|
||||
// raw compact JWT string "header.payload.signature".
|
||||
let groups = extract_groups_claim(&id_token);
|
||||
|
||||
let user = User {
|
||||
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
|
||||
// The verified phone number lives at userinfo, not in the ID token,
|
||||
// for providers that give one (Vipps). A provider that has none, or
|
||||
// no userinfo endpoint, leaves it empty; nothing depends on it.
|
||||
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
|
||||
Ok(request) => request
|
||||
.request_async(&oidc.http)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
|
||||
Err(_) => None,
|
||||
};
|
||||
let mut user = User {
|
||||
sub: claims.subject().as_str().to_string(),
|
||||
username,
|
||||
display_name,
|
||||
groups,
|
||||
phone,
|
||||
email,
|
||||
};
|
||||
// What they belong to, as portal keeps it: every invitation for
|
||||
// them becomes a membership now, and their active memberships join
|
||||
// whatever groups the provider itself asserted.
|
||||
for group in crate::memberships::on_signed_in(&state, &user).await {
|
||||
if !user.groups.contains(&group) {
|
||||
user.groups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate the session id on privilege change, then store the user.
|
||||
session.cycle_id().await.map_err(internal)?;
|
||||
@@ -258,6 +292,10 @@ pub async fn callback(
|
||||
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
|
||||
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
|
||||
|
||||
// Being here is what an invite was for (people.rs): off the
|
||||
// request, since nobody signing in should wait on a desk's list.
|
||||
tokio::spawn(crate::people::on_signed_in(state.clone(), user.clone()));
|
||||
|
||||
tracing::info!(user = %user.username, redirect = %target, "signed in");
|
||||
Ok(Redirect::to(&target))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Which storyline a visitor reads (see `content/variants.rs`): one
|
||||
//! index for the whole site, chosen at their first request and kept in
|
||||
//! a cookie for a year, so every page and every mail they get tells
|
||||
//! the same story. A site that tells one story sets no cookie.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, SET_COOKIE},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub const COOKIE_NAME: &str = "portal_storyline";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Storyline(pub usize);
|
||||
|
||||
/// The index in a request's cookies, if it names one this site tells.
|
||||
fn from_cookies(header: Option<&str>, arity: usize) -> Option<usize> {
|
||||
header?
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|kv| kv.strip_prefix(COOKIE_NAME).and_then(|rest| rest.strip_prefix('=')))
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.filter(|n| *n < arity)
|
||||
}
|
||||
|
||||
/// Evenly, from nothing about the person.
|
||||
fn pick(arity: usize) -> usize {
|
||||
(uuid::Uuid::new_v4().as_u128() % arity.max(1) as u128) as usize
|
||||
}
|
||||
|
||||
pub async fn assign(State(state): State<super::AppState>, mut req: Request, next: Next) -> Response {
|
||||
let arity = state.questions.load().arity();
|
||||
let held = from_cookies(req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), arity);
|
||||
let n = held.unwrap_or_else(|| pick(arity));
|
||||
req.extensions_mut().insert(Storyline(n));
|
||||
let mut res = next.run(req).await;
|
||||
if held.is_none() && arity > 1 {
|
||||
if let Ok(value) = format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() {
|
||||
res.headers_mut().append(SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// The storyline of the request a server function is answering, or
|
||||
/// the first when there is none to ask (a call outside a request).
|
||||
pub async fn current() -> usize {
|
||||
leptos_axum::extract::<axum::Extension<Storyline>>()
|
||||
.await
|
||||
.map(|axum::Extension(Storyline(n))| n)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cookie_names_a_storyline_the_site_tells() {
|
||||
assert_eq!(from_cookies(Some("a=b; portal_storyline=1; c=d"), 3), Some(1));
|
||||
assert_eq!(from_cookies(Some("portal_storyline=7"), 3), None);
|
||||
assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None);
|
||||
assert_eq!(from_cookies(None, 3), None);
|
||||
assert!(pick(1) == 0);
|
||||
assert!((0..50).all(|_| pick(3) < 3));
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
//! Sessions in NATS KV.
|
||||
//!
|
||||
//! A session used to live in a `MemoryStore`, which meant every
|
||||
//! restart signed everybody out: a deploy logged out every desk on
|
||||
//! every site, and the only reason that was bearable is that the desks
|
||||
//! were three people. This keeps them in the JetStream KV that is
|
||||
//! already under everything else here - no new service, no new
|
||||
//! dependency, and the same bucket a second instance of a site would
|
||||
//! read if there ever is one.
|
||||
//!
|
||||
//! Expiry is said twice on purpose. The bucket carries a `max_age`, so
|
||||
//! NATS collects abandoned sessions without anyone sweeping; and a
|
||||
//! record's own `expiry_date` is checked on the way out, because the
|
||||
//! bucket's age is a coarse fence and the record's date is the truth.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use tower_sessions::session::{Id, Record};
|
||||
use tower_sessions::session_store::{Error, Result};
|
||||
|
||||
/// How long an untouched session survives in the bucket. Comfortably
|
||||
/// longer than any expiry `SessionManagerLayer` will set, so the
|
||||
/// record's own date is always what decides; this is only here so a
|
||||
/// session nobody ever comes back for does not sit in NATS forever.
|
||||
const MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60 * 24 * 40);
|
||||
|
||||
pub const BUCKET: &str = "portal_sessions";
|
||||
|
||||
/// A new id is drawn this many times before a collision is reported as
|
||||
/// a backend error. Two random `i128`s colliding is not a thing that
|
||||
/// happens; this exists so a bucket misbehaving cannot spin forever.
|
||||
const ID_ATTEMPTS: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NatsStore {
|
||||
store: async_nats::jetstream::kv::Store,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NatsStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NatsStore").field("bucket", &BUCKET).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl NatsStore {
|
||||
/// Opens the bucket, creating it on first use the way every other
|
||||
/// bucket here is created.
|
||||
pub async fn open(js: &async_nats::jetstream::Context) -> anyhow::Result<Self> {
|
||||
let store = match js.get_key_value(BUCKET).await {
|
||||
Ok(store) => store,
|
||||
Err(_) => {
|
||||
js.create_key_value(async_nats::jetstream::kv::Config {
|
||||
bucket: BUCKET.to_string(),
|
||||
max_age: MAX_AGE,
|
||||
history: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(Self { store })
|
||||
}
|
||||
|
||||
fn encode(record: &Record) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(record).map_err(|e| Error::Encode(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tower_sessions::SessionStore for NatsStore {
|
||||
/// `create` draws a fresh id until the bucket accepts one as new.
|
||||
/// `put` would overwrite a stranger's session on a collision;
|
||||
/// `create` refuses, which is the whole difference.
|
||||
async fn create(&self, record: &mut Record) -> Result<()> {
|
||||
for _ in 0..ID_ATTEMPTS {
|
||||
let bytes = Self::encode(record)?;
|
||||
match self.store.create(&record.id.to_string(), bytes.into()).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => record.id = Id::default(),
|
||||
}
|
||||
}
|
||||
Err(Error::Backend(format!(
|
||||
"could not find an unused session id in {ID_ATTEMPTS} attempts"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn save(&self, record: &Record) -> Result<()> {
|
||||
let bytes = Self::encode(record)?;
|
||||
self.store
|
||||
.put(&record.id.to_string(), bytes.into())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| Error::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
async fn load(&self, id: &Id) -> Result<Option<Record>> {
|
||||
let Some(bytes) = self
|
||||
.store
|
||||
.get(&id.to_string())
|
||||
.await
|
||||
.map_err(|e| Error::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let record: Record =
|
||||
serde_json::from_slice(&bytes).map_err(|e| Error::Decode(e.to_string()))?;
|
||||
// An expired record is gone as far as anyone asking is
|
||||
// concerned, whether or not the bucket has collected it yet.
|
||||
if record.expiry_date <= tower_sessions::cookie::time::OffsetDateTime::now_utc() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &Id) -> Result<()> {
|
||||
// Purge, not delete: a signed-out session should leave no
|
||||
// history in the bucket to read back.
|
||||
self.store
|
||||
.purge(&id.to_string())
|
||||
.await
|
||||
.map_err(|e| Error::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
+283
-42
@@ -57,13 +57,23 @@ pub struct UploadResponse {
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// POST /upload - streams the first `file` field straight through to
|
||||
/// Garage and hands back its object key. Server-side hygiene beyond the
|
||||
/// HTML `accept` hint (a UX-only signal, not a boundary): a hard size
|
||||
/// cap, and the upload is rejected outright if Garage isn't configured
|
||||
/// rather than silently succeeding nowhere.
|
||||
/// POST /upload - takes the `file` field, plus the `question` and
|
||||
/// `alternative` it belongs to, and hands back its object key.
|
||||
///
|
||||
/// The two names are not decoration: they are what makes this an
|
||||
/// authorized route rather than an open one. An upload is half a
|
||||
/// submission, so it asks the policy exactly what the submission will
|
||||
/// ask - `Submit` on that page - and it only accepts a page that
|
||||
/// actually declares a `type: file` field. Without that, anybody who
|
||||
/// could reach the host could put twenty megabytes in the bucket as
|
||||
/// often as they liked, on a site whose forms are public.
|
||||
///
|
||||
/// Server-side hygiene beyond the HTML `accept` hint (a UX signal, not
|
||||
/// a boundary): a hard size cap, and a refusal rather than a silent
|
||||
/// success when Garage isn't configured.
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
session: tower_sessions::Session,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
||||
let garage = state.garage.as_ref().ok_or((
|
||||
@@ -71,49 +81,280 @@ pub async fn upload(
|
||||
"uploads are not configured".to_string(),
|
||||
))?;
|
||||
|
||||
while let Some(field) = multipart
|
||||
let mut question_id = String::new();
|
||||
let mut alternative = String::new();
|
||||
let mut file: Option<(String, String, Vec<u8>)> = None;
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
{
|
||||
if field.name() != Some("file") {
|
||||
continue;
|
||||
}
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let filename = field.file_name().unwrap_or("upload").to_string();
|
||||
// Chunk by chunk, refused the moment the cap is crossed: an
|
||||
// oversized body is never buffered whole before being turned
|
||||
// away. The route's body limit (main.rs) is the outer fence.
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
let mut field = field;
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
{
|
||||
if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES {
|
||||
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
|
||||
match field.name() {
|
||||
Some("question") | Some("alternative") => {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
if name == "question" {
|
||||
question_id = text;
|
||||
} else {
|
||||
alternative = text;
|
||||
}
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
Some("file") => {
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let filename = field.file_name().unwrap_or("upload").to_string();
|
||||
// Chunk by chunk, refused the moment the cap is
|
||||
// crossed: an oversized body is never buffered whole
|
||||
// before being turned away. The route's body limit
|
||||
// (main.rs) is the outer fence.
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
{
|
||||
if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES {
|
||||
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
file = Some((filename, content_type, bytes));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
|
||||
garage
|
||||
.client
|
||||
.put_object()
|
||||
.bucket(&garage.bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.content_type(content_type)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
|
||||
|
||||
return Ok(Json(UploadResponse { key }));
|
||||
}
|
||||
|
||||
Err((StatusCode::BAD_REQUEST, "no file field in request".to_string()))
|
||||
let (filename, content_type, bytes) =
|
||||
file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?;
|
||||
|
||||
// Nothing has been written yet. Everything below decides whether
|
||||
// anything will be.
|
||||
if !may_upload(&state, &session, &question_id, &alternative).await {
|
||||
return Err((StatusCode::FORBIDDEN, "not authorized to upload here".to_string()));
|
||||
}
|
||||
|
||||
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
|
||||
garage
|
||||
.client
|
||||
.put_object()
|
||||
.bucket(&garage.bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.content_type(content_type)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
|
||||
|
||||
Ok(Json(UploadResponse { key }))
|
||||
}
|
||||
|
||||
/// Whether this caller may attach a file to that page's alternative:
|
||||
/// the alternative has to exist, has to declare a `type: file` field,
|
||||
/// and the policy has to let this principal submit that page. The same
|
||||
/// question `submit_answer` asks, asked before the bytes land rather
|
||||
/// than after.
|
||||
async fn may_upload(
|
||||
state: &AppState,
|
||||
session: &tower_sessions::Session,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
) -> bool {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
|
||||
let questions = state.questions.load();
|
||||
let Some(page) = page_taking_files(&questions, question_id, alternative) else {
|
||||
return false;
|
||||
};
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
||||
let principal = crate::access::Principal::from_user(user.as_ref());
|
||||
crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&principal,
|
||||
crate::access::Action::Submit,
|
||||
&crate::access::Resource::Page { id: page },
|
||||
serde_json::json!({ "alternative": alternative }),
|
||||
)
|
||||
.allow
|
||||
}
|
||||
|
||||
/// The resolved page id, when that page has an alternative of this
|
||||
/// name that is open and actually asks for a file. `None` for a page
|
||||
/// that does not exist, an alternative that does not, one that is
|
||||
/// disabled, or one that asks for no file at all - an upload that
|
||||
/// names none of the site's own file fields is not an upload this site
|
||||
/// asked for.
|
||||
fn page_taking_files(
|
||||
questions: &std::collections::HashMap<String, crate::content::Question>,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
) -> Option<String> {
|
||||
let question = crate::content::resolve_question(questions, question_id)?;
|
||||
question
|
||||
.alternatives
|
||||
.iter()
|
||||
.find(|a| a.name == alternative && !a.disabled)?
|
||||
.features
|
||||
.iter()
|
||||
.any(|f| f.requirements.iter().any(|r| r.kind == "file"))
|
||||
.then(|| question.id.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn questions(yaml: &str) -> std::collections::HashMap<String, crate::content::Question> {
|
||||
crate::content::build_questions(&[("index.yaml".to_string(), yaml.to_string())]).unwrap()
|
||||
}
|
||||
|
||||
const PAGE: &str = "name: Home
|
||||
alternatives:
|
||||
- name: Send drawings
|
||||
record_as: enquiries
|
||||
features:
|
||||
- name: Files
|
||||
requirements:
|
||||
- { name: drawings, type: file, optional: true }
|
||||
- name: Just ask
|
||||
features:
|
||||
- name: Words
|
||||
requirements:
|
||||
- { name: question, type: textarea }
|
||||
";
|
||||
|
||||
// The bytes are only ever written for a page of this site that
|
||||
// asked for a file. Everything else is somebody using the host as
|
||||
// a disk.
|
||||
#[test]
|
||||
fn an_upload_must_name_a_page_that_asked_for_a_file() {
|
||||
let qs = questions(PAGE);
|
||||
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string()));
|
||||
// An alternative on the same page that takes no file.
|
||||
assert_eq!(page_taking_files(&qs, "/", "Just ask"), None);
|
||||
// Names that are not there at all.
|
||||
assert_eq!(page_taking_files(&qs, "/", "Nonexistent"), None);
|
||||
assert_eq!(page_taking_files(&qs, "/nowhere", "Send drawings"), None);
|
||||
assert_eq!(page_taking_files(&qs, "", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_alternative_takes_nothing() {
|
||||
let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n"));
|
||||
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /attachment/{bucket}/{record}/{field} - the other half of an
|
||||
/// upload. A `type: file` field stores an object key, which on its own
|
||||
/// is a string a desk can read and nobody can open; this streams the
|
||||
/// object back.
|
||||
///
|
||||
/// The key is never the credential. It is not even taken from the
|
||||
/// caller: the caller names a record, and the key is read out of that
|
||||
/// record's own field, so a file can only be fetched through a record
|
||||
/// somebody is allowed to see. Guessing keys gets you nowhere, and a
|
||||
/// key that leaks out of a desk is not a link to anything.
|
||||
pub async fn attachment(
|
||||
State(state): State<AppState>,
|
||||
session: tower_sessions::Session,
|
||||
axum::extract::Path((bucket, record, field)): axum::extract::Path<(String, String, String)>,
|
||||
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let garage = state.garage.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"uploads are not configured".to_string(),
|
||||
))?;
|
||||
|
||||
// Refusals read the same as a miss all the way down, so this
|
||||
// cannot be used to learn which buckets, records or fields exist.
|
||||
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
||||
|
||||
if !may_read_bucket(&state, &session, &bucket).await {
|
||||
return Err(missing());
|
||||
}
|
||||
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
|
||||
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
|
||||
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
||||
let key = answer
|
||||
.responses
|
||||
.get(&field)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(missing)?;
|
||||
|
||||
let object = garage
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&garage.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| missing())?;
|
||||
|
||||
let content_type = object
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let body = object
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))?
|
||||
.into_bytes();
|
||||
|
||||
// The stored key is `<uuid>-<original name>`; the person who gets
|
||||
// the file should see the name they sent, not the uuid.
|
||||
let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key);
|
||||
let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], "");
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Whether this caller may see this bucket at all: whether any feature
|
||||
/// in the loaded content lists it and the policy lets them read that
|
||||
/// feature. The desk that shows the record is what grants the file.
|
||||
async fn may_read_bucket(
|
||||
state: &AppState,
|
||||
session: &tower_sessions::Session,
|
||||
bucket: &str,
|
||||
) -> bool {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
||||
let principal = crate::access::Principal::from_user(user.as_ref());
|
||||
let access = state.access.load();
|
||||
state.questions.load().values().any(|question| {
|
||||
question.alternatives.iter().any(|alternative| {
|
||||
alternative.features.iter().any(|feature| {
|
||||
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
||||
&& access.allows(
|
||||
&principal,
|
||||
crate::access::Action::Read,
|
||||
&crate::access::Resource::Feature {
|
||||
page: question.id.clone(),
|
||||
alternative: alternative.name.clone(),
|
||||
feature: feature.name.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+218
-1
@@ -297,6 +297,94 @@ main.not-found {
|
||||
}
|
||||
}
|
||||
|
||||
/* The way down the tree: one level toward the front door, from any
|
||||
page, as a line at the foot of it. */
|
||||
.down-the-tree-foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
margin: 2.6rem auto 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.down-the-tree-foot a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
border-bottom: 0.06rem solid var(--line);
|
||||
}
|
||||
|
||||
.down-the-tree-foot a:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Only for the person a page names as responsible: a quiet card at
|
||||
the foot of their own page for suggesting a change to it. */
|
||||
.suggest-edit {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 44rem;
|
||||
margin: 2.4rem auto 0;
|
||||
padding: 1.2rem 1.4rem;
|
||||
border: 0.06rem dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.suggest-edit h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.suggest-edit p {
|
||||
color: var(--ink-dim);
|
||||
margin: 0 0 0.8rem;
|
||||
}
|
||||
|
||||
.suggest-edit textarea {
|
||||
width: 100%;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.suggest-edit h3 {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-dim);
|
||||
margin: 1.4rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.suggest-edit-mine {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.suggest-edit-mine li {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 0.06rem solid var(--line);
|
||||
}
|
||||
|
||||
.suggest-edit-state {
|
||||
flex: none;
|
||||
min-width: 6.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.suggest-edit-state[data-state="done"] {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.suggest-edit-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.question-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1006,6 +1094,22 @@ textarea:focus {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The page about a listed record, when the list offers one. */
|
||||
.answer-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 0.1rem solid var(--accent);
|
||||
}
|
||||
|
||||
.answer-link:hover {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--ink);
|
||||
}
|
||||
|
||||
.answer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1120,4 +1224,117 @@ 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);
|
||||
}
|
||||
|
||||
/* A loading slot holds its line's worth of space and says nothing.
|
||||
The words that belong here have not arrived yet, and "loading…" is
|
||||
not one of them. */
|
||||
.resource-loading {
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
+185
-1
@@ -34,7 +34,7 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// The submission: a KV record in the initial state, plus the first event.
|
||||
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now)
|
||||
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now, 0)
|
||||
.await
|
||||
.expect("store");
|
||||
let store = js.get_key_value(&bucket).await.expect("bucket exists");
|
||||
@@ -63,4 +63,188 @@ 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 state_before_the_note = record.state.clone();
|
||||
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, state_before_the_note, "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");
|
||||
}
|
||||
|
||||
/// A session outlives the process that made it. The `MemoryStore` this
|
||||
/// replaced could not: every deploy signed out every desk, and the
|
||||
/// only proof that this one does not is a store opened twice against
|
||||
/// the same JetStream, as two runs of the binary would.
|
||||
#[tokio::test]
|
||||
async fn a_session_survives_the_process_that_made_it() {
|
||||
use tower_sessions::session::{Id, Record};
|
||||
use tower_sessions::SessionStore;
|
||||
|
||||
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
||||
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
||||
return;
|
||||
};
|
||||
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
|
||||
let store = portal::sessions::NatsStore::open(&js).await.expect("open");
|
||||
|
||||
let mut record = Record {
|
||||
id: Id::default(),
|
||||
data: Default::default(),
|
||||
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
|
||||
+ tower_sessions::cookie::time::Duration::hours(1),
|
||||
};
|
||||
record.data.insert("who".into(), json!("ingrid"));
|
||||
store.create(&mut record).await.expect("create");
|
||||
|
||||
// A second store over the same JetStream is what the next boot of
|
||||
// the binary has: nothing carried over in memory.
|
||||
let next_boot = portal::sessions::NatsStore::open(&js).await.expect("reopen");
|
||||
let found = next_boot.load(&record.id).await.expect("load").expect("still there");
|
||||
assert_eq!(found.data.get("who"), Some(&json!("ingrid")));
|
||||
|
||||
// `create` never writes over somebody else's session: asked to
|
||||
// create one whose id is taken, it takes a new id instead.
|
||||
let mut collide = Record { id: record.id, data: Default::default(), expiry_date: record.expiry_date };
|
||||
next_boot.create(&mut collide).await.expect("create beside it");
|
||||
assert_ne!(collide.id, record.id, "an id already in use must not be reused");
|
||||
assert_eq!(
|
||||
next_boot.load(&record.id).await.expect("load").expect("original intact").data.get("who"),
|
||||
Some(&json!("ingrid")),
|
||||
"the original session must be untouched"
|
||||
);
|
||||
|
||||
// An expired record is gone to anyone asking, whatever the bucket
|
||||
// still holds.
|
||||
let stale = Record {
|
||||
id: Id::default(),
|
||||
data: Default::default(),
|
||||
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
|
||||
- tower_sessions::cookie::time::Duration::minutes(1),
|
||||
};
|
||||
next_boot.save(&stale).await.expect("save stale");
|
||||
assert!(next_boot.load(&stale.id).await.expect("load stale").is_none());
|
||||
|
||||
// Signing out leaves nothing to read back.
|
||||
next_boot.delete(&record.id).await.expect("delete");
|
||||
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
|
||||
next_boot.delete(&collide.id).await.ok();
|
||||
}
|
||||
|
||||
/// A membership is a case portal keeps itself: invited by a desk or a
|
||||
/// decision, active from the person's first sign-in, removed by a desk,
|
||||
/// and invited again after that. What the person belongs to is read
|
||||
/// back from the same records, matched on phone number or address.
|
||||
#[tokio::test]
|
||||
async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back() {
|
||||
use portal::memberships::{self, Identity, ACTIVE, INVITED, REMOVED};
|
||||
|
||||
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
||||
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
||||
return;
|
||||
};
|
||||
let nats = async_nats::connect(&url).await.expect("connect");
|
||||
let js = async_nats::jetstream::new(nats.clone());
|
||||
ensure_stream(&js).await.expect("event stream");
|
||||
// A clean bucket per run.
|
||||
let _ = js.delete_key_value(memberships::BUCKET).await;
|
||||
let aggregates = portal::content::with_builtin_aggregates(Default::default());
|
||||
let questions = std::collections::HashMap::new();
|
||||
let access = portal::access::Policy::from_content(&questions, &aggregates);
|
||||
let state = portal::server::AppState {
|
||||
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
|
||||
nats,
|
||||
jetstream: js,
|
||||
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(questions))),
|
||||
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(aggregates))),
|
||||
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(Default::default()))),
|
||||
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
|
||||
gitea_base: String::new(),
|
||||
content_raw_base: String::new(),
|
||||
content_branch: "main".into(),
|
||||
oidc: std::sync::Arc::new(portal::server::oidc::Oidc::disabled()),
|
||||
garage: None,
|
||||
};
|
||||
|
||||
// Groups of this run's own: the event log outlives the bucket, and
|
||||
// a record id seen before would replay its old history.
|
||||
let run = chrono::Utc::now().timestamp_millis();
|
||||
let styret = format!("styret{run}");
|
||||
let medlemmer = format!("medlemmer{run}");
|
||||
let (styret, medlemmer) = (styret.as_str(), medlemmer.as_str());
|
||||
|
||||
// A desk invites Kari by phone number; a decision grants her a
|
||||
// second group by address. Both are invitations until she is seen.
|
||||
let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None };
|
||||
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
|
||||
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
|
||||
assert!(first.created && first.reset_link.is_none());
|
||||
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
|
||||
assert!(!again.created, "a second invite into the same group finds the first");
|
||||
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
|
||||
let records = memberships::all(&state).await;
|
||||
assert_eq!(records.len(), 2);
|
||||
assert!(records.iter().all(|r| r.state == INVITED));
|
||||
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
|
||||
|
||||
// She signs in: the provider knows her subject, phone and address.
|
||||
let kari = portal::auth::User {
|
||||
sub: "vipps-abc".into(),
|
||||
username: "kari".into(),
|
||||
display_name: "Kari".into(),
|
||||
groups: vec![],
|
||||
phone: Some("912 34 567".into()),
|
||||
email: Some("Kari@Vel.no".into()),
|
||||
};
|
||||
let mut groups = memberships::on_signed_in(&state, &kari).await;
|
||||
groups.sort();
|
||||
assert_eq!(groups, vec![medlemmer, styret]);
|
||||
let records = memberships::all(&state).await;
|
||||
assert!(records.iter().all(|r| r.state == ACTIVE));
|
||||
// Both records now know her by everything the provider verified.
|
||||
for r in &records {
|
||||
let held = memberships::identity_of(r);
|
||||
assert_eq!(held.sub.as_deref(), Some("vipps-abc"));
|
||||
assert_eq!(held.phone.as_deref(), Some("+4791234567"));
|
||||
assert_eq!(held.email.as_deref(), Some("kari@vel.no"));
|
||||
}
|
||||
|
||||
// A desk takes her out of styret; the record stays, as removed.
|
||||
assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke"));
|
||||
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
|
||||
let records = memberships::all(&state).await;
|
||||
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
|
||||
assert_eq!(styret_record.state, REMOVED);
|
||||
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
|
||||
|
||||
// Invited again: the same record, invited, and hers at the next sign-in.
|
||||
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant");
|
||||
assert!(back.created);
|
||||
let records = memberships::all(&state).await;
|
||||
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
|
||||
let groups = memberships::on_signed_in(&state, &kari).await;
|
||||
assert!(groups.contains(&styret.to_string()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user