Compare commits
21
Commits
@@ -38,6 +38,25 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --features ssr
|
run: cargo test --features ssr
|
||||||
|
|
||||||
|
# Every plan under plans/ built deterministically and held to the
|
||||||
|
# lint, both by this very portal: a builder change that renders a
|
||||||
|
# site the lint rejects fails here, not at the next training run.
|
||||||
|
# (portal-trainer's CI did this until the trainer moved in.)
|
||||||
|
- name: Build and lint every plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cargo build --features ssr --bin portal
|
||||||
|
bin="$CARGO_TARGET_DIR/debug/portal"
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
lint="$tmp/portal-lint"
|
||||||
|
printf '#!/bin/sh\nexec %s lint "$@"\n' "$bin" > "$lint" && chmod +x "$lint"
|
||||||
|
for plan in plans/*.yaml; do
|
||||||
|
name=$(basename "$plan" .yaml)
|
||||||
|
echo "== $plan"
|
||||||
|
rm -rf "$tmp/plan-$name"
|
||||||
|
"$bin" build --plan "$plan" --out "$tmp/plan-$name" --lint "$lint"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Stop the throwaway JetStream
|
- name: Stop the throwaway JetStream
|
||||||
if: always()
|
if: always()
|
||||||
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ One line per change, grouped by the release that shipped it. Newest
|
|||||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
content repos pin the tag they run (see the end of this file).
|
||||||
|
|
||||||
|
## 0.6.23 (2026-10-07)
|
||||||
|
|
||||||
|
- A configuration profile's per-payload `PayloadVersion` is an integer, as the plist key is typed; it was a string, which iOS may refuse.
|
||||||
|
|
||||||
|
## 0.6.22 (2026-10-07)
|
||||||
|
|
||||||
|
- `/profile.mobileconfig` renders an Apple configuration profile from the mail server's own PACC document (`profile.pacc` in `site.yaml`), so iOS can be set up in one install instead of probing for ports it cannot reach. Mail, CalDAV and CardDAV in one profile; every host, port and path from PACC, never written down twice; no address, username or password in the document, so one profile serves everyone and iOS asks at install; identifiers derived from the site, so reinstalling replaces the accounts rather than duplicating them (docs: content-reference, "A configuration profile").
|
||||||
|
|
||||||
|
## 0.6.21 (2026-10-07)
|
||||||
|
|
||||||
|
- A content repo's `.well-known/` is served at the site root (`/.well-known/security.txt`, `/.well-known/assetlinks.json`, `/.well-known/matrix/server`), and a miss there is a 404 instead of the app shell answering 200 with an HTML page. RFC 8615 reserves the prefix and a question's id cannot begin with a dot, so nothing a repo ships can shadow a page. An extensionless file under it is typed as JSON, by the convention those files follow; `.txt`, `.xml`, `.webmanifest` and `.mobileconfig` are typed too, the last so an Apple configuration profile offers to install rather than download.
|
||||||
|
|
||||||
|
## 0.6.20 (2026-10-05)
|
||||||
|
|
||||||
|
- A releases card lets a platform's file carry an architecture: a Mac visitor is offered the `macos-arm64` file, not sent to TestFlight past it.
|
||||||
|
|
||||||
|
## 0.6.19 (2026-10-05)
|
||||||
|
|
||||||
|
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
|
||||||
|
|
||||||
|
## 0.6.18 (2026-10-03)
|
||||||
|
|
||||||
|
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
|
||||||
|
- `portal generate` follows its findings through: each structure round is logged as fixed, still open, new and reopened; a fix round is shown the open findings with their fixes and only the files they name; a change that reopens a closed finding is undone; and the phase stops when a round closes nothing, writing what is left to run.log. A hesitant visitor no longer triggers a wording rewrite by itself, only a misroute does. A lint without `--findings` is still driven the old way.
|
||||||
|
|
||||||
|
## 0.6.17 (2026-10-02)
|
||||||
|
|
||||||
|
- A desk shows every record still moving and only the three most recently finished (a final state: no move out of it), with "Show older" for three more at a time. A desk that has run for a year is still about the work in hand.
|
||||||
|
- Mail variables are one set for every kind of mail (docs: Mail). Every mail has `{site}` and `{site_name}`; a state mail also has `{link}`, `{self_link}` and `{desk_link}`: the reader's own page for the record (a self-service page with `?chain=&email=`, when the record's state allows a move there), the desk that moves it, and `{link}` choosing between them by who the mail is to. `{link}` in a state mail used to render as nothing.
|
||||||
|
- The lint follows every state of every bucket a need lands in: a state no desk, link or clock reaches fails; a state a record can reach and never leave fails, after "done" too; a declared move nobody offers warns; a desk gated to one group on the page and another on its list warns; working stages must follow each other in the business's order; a mail from an unreachable state warns, and one naming a field no form collects fails. Clocks (`after:`) count as moves, and a visitor's own link only from the states it names. Each finding ends in the fix: the `{ from, to, label }` and the desk page it goes on, or the group that already has the button. A schema error no longer hides the needs findings.
|
||||||
|
|
||||||
## 0.6.16 (2026-10-01)
|
## 0.6.16 (2026-10-01)
|
||||||
|
|
||||||
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.15"
|
version = "0.6.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.15"
|
version = "0.6.23"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [
|
|||||||
"FileList",
|
"FileList",
|
||||||
"FormData",
|
"FormData",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
|
"Navigator",
|
||||||
"Request",
|
"Request",
|
||||||
"RequestInit",
|
"RequestInit",
|
||||||
"Response",
|
"Response",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- [Getting started](getting-started.md)
|
- [Getting started](getting-started.md)
|
||||||
- [Content reference](content-reference.md)
|
- [Content reference](content-reference.md)
|
||||||
- [State machines](state-machines.md)
|
- [State machines](state-machines.md)
|
||||||
|
- [Mail](mail.md)
|
||||||
- [Access](access.md)
|
- [Access](access.md)
|
||||||
- [Storylines](storylines.md)
|
- [Storylines](storylines.md)
|
||||||
- [Memberships](memberships.md)
|
- [Memberships](memberships.md)
|
||||||
|
|||||||
@@ -97,6 +97,79 @@ Pictures in the content repo are served from this origin under
|
|||||||
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
|
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
|
||||||
hour. Keep them web-sized: around 1400 pixels tall is plenty.
|
hour. Keep them web-sized: around 1400 pixels tall is plenty.
|
||||||
|
|
||||||
|
## Files at the site root
|
||||||
|
|
||||||
|
A repo's `.well-known/` directory is served at the root of the site, at
|
||||||
|
the same paths it has in the repo:
|
||||||
|
|
||||||
|
```
|
||||||
|
.well-known/security.txt -> https://<site>/.well-known/security.txt
|
||||||
|
.well-known/assetlinks.json -> https://<site>/.well-known/assetlinks.json
|
||||||
|
.well-known/matrix/server -> https://<site>/.well-known/matrix/server
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the one part of the URL space a repo may write into directly.
|
||||||
|
RFC 8615 reserves `/.well-known/`, and a question's id comes from a file
|
||||||
|
name under `questions/`, which cannot begin with a dot - so nothing
|
||||||
|
shipped here can shadow a page. A path that is not in the repo answers
|
||||||
|
**404**, not the app shell, so a program looking for one of these can
|
||||||
|
tell a missing file from a present one.
|
||||||
|
|
||||||
|
A file with no extension is served as JSON, which is what the
|
||||||
|
extensionless well-known files are. `.txt`, `.xml`, `.webmanifest` and
|
||||||
|
`.mobileconfig` are typed as themselves; the last matters because iOS
|
||||||
|
only offers to *install* a configuration profile that arrives as
|
||||||
|
`application/x-apple-aspen-config`.
|
||||||
|
|
||||||
|
Paths a reverse proxy claims in front of portal never reach the repo -
|
||||||
|
mail autoconfiguration (`/.well-known/autoconfig/`) and the CalDAV and
|
||||||
|
CardDAV redirects are usually handed to a mail server that generates
|
||||||
|
them.
|
||||||
|
|
||||||
|
## A configuration profile
|
||||||
|
|
||||||
|
iOS has no autoconfiguration for IMAP accounts - Apple Mail reads neither
|
||||||
|
Mozilla autoconfig nor Microsoft Autodiscover, which are for ActiveSync - so
|
||||||
|
adding an account by hand leaves the device probing for ports. A probe that
|
||||||
|
meets a *filtered* port, a firewall that drops rather than refuses, waits out
|
||||||
|
a TCP timeout, and the account comes back "could not be verified", which reads
|
||||||
|
to the person as a wrong password. An Apple configuration profile ends the
|
||||||
|
guessing by stating the hosts and the ports, and it carries mail, calendars
|
||||||
|
and contacts in a single install.
|
||||||
|
|
||||||
|
Portal renders one at `/profile.mobileconfig` when `site.yaml` says where to
|
||||||
|
get the values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profile:
|
||||||
|
pacc: https://mail.example.no/.well-known/user-agent-configuration.json
|
||||||
|
name: Example # what the accounts are called on the device
|
||||||
|
organization: Example AS # the profile's publisher, defaults to name
|
||||||
|
```
|
||||||
|
|
||||||
|
`pacc` is the mail server's **PACC** document - the standard that replaces
|
||||||
|
guessing at hostnames, defined to be fetched from the mail domain itself, and
|
||||||
|
served by Stalwart among others. Every host, port and collection path in the
|
||||||
|
profile comes from it, so nothing about the server is written down a second
|
||||||
|
place to drift from the first: move a collection on the server and the profile
|
||||||
|
moves with it. Where PACC states no port, the registered implicit-TLS default
|
||||||
|
is used (993 for IMAP, 465 for submission, 443 for DAV).
|
||||||
|
|
||||||
|
What the profile contains is only what PACC already says in public. It carries
|
||||||
|
**no address and no username** - iOS asks for those and the password at
|
||||||
|
install - so one document serves everyone with an account and may be fetched
|
||||||
|
by anyone. Payload identifiers are derived from the site and the payload
|
||||||
|
rather than drawn at random, so reinstalling replaces the accounts instead of
|
||||||
|
stacking duplicates beside them.
|
||||||
|
|
||||||
|
Two things it cannot do. A profile Portal renders is unsigned, so iOS shows it
|
||||||
|
as *Unverified* while still offering to install it; signing needs a
|
||||||
|
certificate the device already trusts. And a PACC document describing no
|
||||||
|
protocol iOS understands - JMAP alone, or an incoming server with no outgoing
|
||||||
|
one - is an error rather than a half-empty profile that looks like it worked.
|
||||||
|
|
||||||
|
No `profile:` means `/profile.mobileconfig` is a 404.
|
||||||
|
|
||||||
## An alternative
|
## An alternative
|
||||||
|
|
||||||
One choice. Never two actions behind one heading: if two things can be
|
One choice. Never two actions behind one heading: if two things can be
|
||||||
@@ -255,6 +328,7 @@ reads is never a client-supplied parameter.
|
|||||||
states: [approved, refused] # only these; empty means all
|
states: [approved, refused] # only these; empty means all
|
||||||
where: { field: case, equals: "{key}" }
|
where: { field: case, equals: "{key}" }
|
||||||
jq: ".[] | {name, url: .html_url}"
|
jq: ".[] | {name, url: .html_url}"
|
||||||
|
limit: 1 # at most this many, after jq
|
||||||
empty: Nothing decided yet.
|
empty: Nothing decided yet.
|
||||||
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
||||||
transitions: # the desk's buttons
|
transitions: # the desk's buttons
|
||||||
@@ -264,6 +338,14 @@ reads is never a client-supplied parameter.
|
|||||||
`source.kind` is one of `kv`, `gitea_starred`,
|
`source.kind` is one of `kv`, `gitea_starred`,
|
||||||
`gitea_releases`, `url`.
|
`gitea_releases`, `url`.
|
||||||
|
|
||||||
|
`limit: 1` shows only the first item — for a feed where only the newest
|
||||||
|
matters. A releases card wants the cut someone should run, not every
|
||||||
|
cut there has ever been, and a page that grows by one card per release
|
||||||
|
gets longer forever. It applies *after* `jq`, so a filter can reorder
|
||||||
|
and the limit takes the top of that ordering. A source that yields a
|
||||||
|
single record has no length and ignores it; `limit: 0` on a list shows
|
||||||
|
nothing, and the `empty` line speaks instead.
|
||||||
|
|
||||||
A spec with neither `public: true` nor `requires_group` is
|
A spec with neither `public: true` nor `requires_group` is
|
||||||
unreachable: reads fail closed, and mutations always need a group
|
unreachable: reads fail closed, and mutations always need a group
|
||||||
whatever `public` says.
|
whatever `public` says.
|
||||||
@@ -285,6 +367,26 @@ markdown on stricter rules than the site's own: paragraphs, line
|
|||||||
breaks as typed, lists, emphasis and links to https, mailto or tel
|
breaks as typed, lists, emphasis and links to https, mailto or tel
|
||||||
(marked `nofollow ugc`); no images, no raw HTML.
|
(marked `nofollow ugc`); no images, no raw HTML.
|
||||||
|
|
||||||
|
A desk (a list with `transitions`) shows every record still moving,
|
||||||
|
and of those in a final state - one the state machine has no move out
|
||||||
|
of - only the three most recently decided, newest first. "Show older"
|
||||||
|
under the list brings three more each time it is pressed. The desk
|
||||||
|
stays about the work in hand however long the site has run; nothing
|
||||||
|
is hidden from a read-only list, and `leaves_after` still takes a
|
||||||
|
record out of every list altogether.
|
||||||
|
|
||||||
|
A **releases manifest** - a `kind: url` resource whose JSON is
|
||||||
|
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
|
||||||
|
url, sha256, size }] }], channels: { arch: { repo, package, note },
|
||||||
|
ios: { testflight, note } } }`, newest version first, as a release
|
||||||
|
pipeline writes it - renders as one card: the newest version, the file
|
||||||
|
for the visitor's own platform first (read from the browser: `ios`,
|
||||||
|
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
|
||||||
|
other files and the channels folded under "All downloads". Linux gets
|
||||||
|
the package repo lines beside its file; iPhones and Macs get
|
||||||
|
TestFlight. Platforms a manifest lacks fall through to the folded
|
||||||
|
list.
|
||||||
|
|
||||||
`states` and `where` narrow a listing. They matter most for a register
|
`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
|
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
|
was withdrawn — while the desk working the same bucket is a different
|
||||||
|
|||||||
+37
-1
@@ -34,7 +34,43 @@ in `needs.yaml`, when it has one:
|
|||||||
- the handling group holds a desk over that bucket, and every
|
- the handling group holds a desk over that bucket, and every
|
||||||
finished state is reachable through the buttons it (and any
|
finished state is reachable through the buttons it (and any
|
||||||
`also_moved_by` group) offers; no record strands on the way;
|
`also_moved_by` group) offers; no record strands on the way;
|
||||||
- every stage the business named is a state of the machine.
|
- every stage the business named is a state of the machine, and each
|
||||||
|
working stage can follow the one before it;
|
||||||
|
- across each bucket, whichever need brought a record in: every state
|
||||||
|
is somewhere a record can get to, no state a record gets to holds it
|
||||||
|
with no way on (before done or after), every declared move is a
|
||||||
|
button, a visitor's own link or a clock (a move nobody offers warns),
|
||||||
|
no desk is gated to one group on the page and another on its list,
|
||||||
|
and every mail goes out from a state records reach and names only
|
||||||
|
fields the forms collect.
|
||||||
|
|
||||||
|
Every finding says what to change and where: the missing
|
||||||
|
`{ from, to, label }` and the desk page it goes on, the group that
|
||||||
|
already has the button, or the edge aggregates.yaml lacks. A schema
|
||||||
|
error no longer hides the needs findings - both are printed, so one
|
||||||
|
round can fix them together.
|
||||||
|
|
||||||
|
`--findings <out.jsonl>` writes the same findings one JSON object per
|
||||||
|
line: `id`, `level` (`fail`/`warn`), `code`, `scope` (`need`,
|
||||||
|
`bucket`, or `repo` for a schema error), `need` (the need's id, or the
|
||||||
|
bucket's name), `subject` (the state, the move as `from>to`, or the
|
||||||
|
page), `message` and `fix`. The file is written empty when the content
|
||||||
|
is clean. `id` hashes scope, need, code and subject and nothing of the
|
||||||
|
wording, so the same gap keeps its id from one run to the next: a
|
||||||
|
trainer can tell what a round closed, what is still open and what it
|
||||||
|
broke. The codes:
|
||||||
|
|
||||||
|
| code | |
|
||||||
|
|---|---|
|
||||||
|
| `schema` | content the site would refuse to load |
|
||||||
|
| `need-duplicate`, `persona-unknown-need` | needs.yaml itself |
|
||||||
|
| `entry-missing`, `entry-gated`, `entry-no-responsible` | the page a visitor arrives on |
|
||||||
|
| `path-missing`, `path-closed`, `path-over-budget`, `path-no-action` | getting the answer recorded |
|
||||||
|
| `machine-missing`, `desk-missing`, `desk-gated-twice` | who works the bucket |
|
||||||
|
| `done-unknown`, `done-unreachable`, `success-not-done` | finishing a case |
|
||||||
|
| `stage-unknown`, `stage-unreachable`, `stage-order` | the business's own stages |
|
||||||
|
| `state-unreached`, `strand`, `edge-unoffered` | every state and move of the bucket |
|
||||||
|
| `mail-unreachable`, `mail-unknown-field` | what a state mails |
|
||||||
|
|
||||||
A need marked `planned` warns instead of failing. Exit status is the
|
A need marked `planned` warns instead of failing. Exit status is the
|
||||||
verdict, so a content repo's CI is one line.
|
verdict, so a content repo's CI is one line.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Mail
|
||||||
|
|
||||||
|
Portal writes every mail itself and hands it to gdo to deliver
|
||||||
|
(`portal.mail.send`); a reply comes back as a note beside the record
|
||||||
|
(`portal.mail.received`). A mail is a subject and a markdown body with
|
||||||
|
`{placeholders}`, sent as text with an HTML alternative. The kinds of
|
||||||
|
mail are the ones content declares on a state (see
|
||||||
|
[State machines](state-machines.md#mail)), the invite and its
|
||||||
|
rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the
|
||||||
|
note to a person named responsible for a question, and the answers to
|
||||||
|
a suggested change.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that
|
||||||
|
names nothing renders as nothing; a brace that opens no name stays a
|
||||||
|
brace. The variables come in layers, so a template written for one
|
||||||
|
kind of mail reads the same in another.
|
||||||
|
|
||||||
|
**On every mail**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `{site}` | the instance's public URL, no trailing slash |
|
||||||
|
| `{site_name}` | site.yaml's `title` |
|
||||||
|
|
||||||
|
**On a mail about a record** (every state mail)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `{field}` | any field of the record, as the form sent it |
|
||||||
|
| `{chain}` | the record's id |
|
||||||
|
| `{email}` | the record's `email` field, trimmed |
|
||||||
|
| `{bucket}`, `{state}` | where it is and the state it just entered |
|
||||||
|
| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` |
|
||||||
|
| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address |
|
||||||
|
| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket |
|
||||||
|
| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty |
|
||||||
|
|
||||||
|
`{link}` is the one to reach for. "Se meldingen og avtal befaring:
|
||||||
|
{link}" sent to the caretaker opens the desk the report is on; "Feilen
|
||||||
|
du meldte om er utbedret. {link}" sent to the tenant opens their own
|
||||||
|
case where they can still act on it, or the site when there is
|
||||||
|
nothing left for them to do.
|
||||||
|
|
||||||
|
**On an invite and the note to someone responsible**, beside every
|
||||||
|
field of the invite's form: `{name}`, `{username}`, `{email}`,
|
||||||
|
`{group}` (invite), `{questions}` (responsible), `{expires}`, and
|
||||||
|
`{link}`, which here is the one-time sign-in link.
|
||||||
|
|
||||||
|
## What the lint holds a mail to
|
||||||
|
|
||||||
|
A state mail that sends from a state no record reaches warns; one that
|
||||||
|
names a field no form recording into the bucket collects fails, since
|
||||||
|
it would go out with a blank where the field should be. The lint
|
||||||
|
knows every variable above, so `{link}` and the rest are never
|
||||||
|
flagged. `to: submitter` requires a required `email` on every form
|
||||||
|
filling the bucket, and `to: "{notice.email}"` requires the `notice`
|
||||||
|
field.
|
||||||
@@ -57,7 +57,9 @@ a record arrives in it.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
||||||
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
|
`{email}`, `{bucket}`, `{state}` and `{link}` - the page where the
|
||||||
|
reader acts on the record, their own or the desk's (every variable is
|
||||||
|
in [Mail](mail.md#variables)). The body is markdown; it is sent
|
||||||
as text with an HTML alternative. `to: submitter` means the record's
|
as text with an HTML alternative. `to: submitter` means the record's
|
||||||
`email` field, and the lint then requires every form filling that
|
`email` field, and the lint then requires every form filling that
|
||||||
bucket to collect a required `email`.
|
bucket to collect a required `email`.
|
||||||
|
|||||||
+16
-5
@@ -243,13 +243,24 @@ against the lint. It is kept for the record of what it measured.
|
|||||||
visitor will tolerate.
|
visitor will tolerate.
|
||||||
2. **Structure.** Pages, state machines and desks are generated, then
|
2. **Structure.** Pages, state machines and desks are generated, then
|
||||||
revised against the lint, `iris check`, until every need is met:
|
revised against the lint, `iris check`, until every need is met:
|
||||||
a path within budget, a desk held by the right group, every
|
a path within budget, a desk held by the right group, every state
|
||||||
finished state reachable, no record stranding. The page-writing
|
of every bucket reachable and none stranding a record. The
|
||||||
model sees the needs and never the personas.
|
page-writing model sees the needs and never the personas. Each
|
||||||
|
round the lint writes its findings with stable ids (`--findings`,
|
||||||
|
kept in `<out>/findings/round-N.jsonl`), and the round is logged as
|
||||||
|
what it fixed, what is still open, what is new and what it
|
||||||
|
reopened. A fix round is shown only the open findings, each with
|
||||||
|
the change that closes it, and the files they name (the whole repo
|
||||||
|
when a finding is about getting around the site). A change that
|
||||||
|
reopens a closed finding is undone and the model told what it
|
||||||
|
broke. A round that closes nothing, or leaves as many open as it
|
||||||
|
found, ends the phase: what is left is written to `run.log` rather
|
||||||
|
than polished at again.
|
||||||
3. **Wording.** A separate model plays each persona, blind, first with
|
3. **Wording.** A separate model plays each persona, blind, first with
|
||||||
headings only and then with descriptions. A misroute is a wording
|
headings only and then with descriptions. A misroute is a wording
|
||||||
defect and goes back to the generator. A rewrite that breaks
|
defect and goes back to the generator; a visitor who chose right
|
||||||
structure is reverted.
|
but hesitated is reported and rewrites nothing by itself. A rewrite
|
||||||
|
that breaks structure is reverted.
|
||||||
|
|
||||||
Model output is only ever written to `aggregates.yaml`, `site.yaml`
|
Model output is only ever written to `aggregates.yaml`, `site.yaml`
|
||||||
and `questions/**.yaml` inside the output directory.
|
and `questions/**.yaml` inside the output directory.
|
||||||
|
|||||||
+351
-68
@@ -70,12 +70,22 @@ pub enum Principal {
|
|||||||
|
|
||||||
impl Principal {
|
impl Principal {
|
||||||
pub fn anonymous() -> Self {
|
pub fn anonymous() -> Self {
|
||||||
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
|
Principal::User {
|
||||||
|
sub: "anonymous".into(),
|
||||||
|
groups: Vec::new(),
|
||||||
|
phone: None,
|
||||||
|
email: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
||||||
match user {
|
match user {
|
||||||
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
|
Some(u) => Principal::User {
|
||||||
|
sub: u.sub.clone(),
|
||||||
|
groups: u.groups.clone(),
|
||||||
|
phone: u.phone.clone(),
|
||||||
|
email: u.email.clone(),
|
||||||
|
},
|
||||||
None => Principal::anonymous(),
|
None => Principal::anonymous(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,13 +120,23 @@ impl Action {
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Resource {
|
pub enum Resource {
|
||||||
Page { id: String },
|
Page {
|
||||||
Bucket { name: String },
|
id: String,
|
||||||
|
},
|
||||||
|
Bucket {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
/// One resource block on one alternative of one page. `feature` is
|
/// One resource block on one alternative of one page. `feature` is
|
||||||
/// the feature name, or `feature|requirement` (`|bind` appended)
|
/// the feature name, or `feature|requirement` (`|bind` appended)
|
||||||
/// for a requirement's own resource.
|
/// for a requirement's own resource.
|
||||||
Feature { page: String, alternative: String, feature: String },
|
Feature {
|
||||||
Group { name: String },
|
page: String,
|
||||||
|
alternative: String,
|
||||||
|
feature: String,
|
||||||
|
},
|
||||||
|
Group {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
@@ -164,12 +184,29 @@ impl Policy {
|
|||||||
// Each rule is parsed on its own under its own id, so a
|
// Each rule is parsed on its own under its own id, so a
|
||||||
// decision's reasons name rules, not `policy17`.
|
// decision's reasons name rules, not `policy17`.
|
||||||
let mut sources: Vec<(String, String)> = Vec::new();
|
let mut sources: Vec<(String, String)> = Vec::new();
|
||||||
let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec<Rule>| {
|
let mut emit = |id_base: String,
|
||||||
|
who: String,
|
||||||
|
action: &str,
|
||||||
|
resource: String,
|
||||||
|
when: String,
|
||||||
|
policy: String,
|
||||||
|
text: &mut String,
|
||||||
|
rules: &mut Vec<Rule>| {
|
||||||
let n = ids.entry(id_base.clone()).or_insert(0);
|
let n = ids.entry(id_base.clone()).or_insert(0);
|
||||||
*n += 1;
|
*n += 1;
|
||||||
let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") };
|
let id = if *n == 1 {
|
||||||
|
id_base
|
||||||
|
} else {
|
||||||
|
format!("{id_base}#{n}")
|
||||||
|
};
|
||||||
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
|
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
|
||||||
rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when });
|
rules.push(Rule {
|
||||||
|
id: id.clone(),
|
||||||
|
who,
|
||||||
|
action: action.to_string(),
|
||||||
|
resource,
|
||||||
|
when,
|
||||||
|
});
|
||||||
sources.push((id, policy));
|
sources.push((id, policy));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,7 +216,10 @@ impl Policy {
|
|||||||
let page = quote(&q.id);
|
let page = quote(&q.id);
|
||||||
let (who, principal) = match &q.qualifies {
|
let (who, principal) = match &q.qualifies {
|
||||||
None => ("anyone".to_string(), "principal".to_string()),
|
None => ("anyone".to_string(), "principal".to_string()),
|
||||||
Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
Some(g) => (
|
||||||
|
format!("group {g}"),
|
||||||
|
format!("principal in Group::{}", quote(g)),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
emit(
|
emit(
|
||||||
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
|
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
|
||||||
@@ -197,16 +237,25 @@ impl Policy {
|
|||||||
}
|
}
|
||||||
for req in &feature.requirements {
|
for req in &feature.requirements {
|
||||||
if let Some(spec) = &req.resource {
|
if let Some(spec) = &req.resource {
|
||||||
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
|
specs.push((
|
||||||
|
format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name),
|
||||||
|
spec,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if let Some(bind) = &req.bind {
|
if let Some(bind) = &req.bind {
|
||||||
specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource));
|
specs.push((
|
||||||
|
format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name),
|
||||||
|
&bind.resource,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (fid, spec) in specs {
|
for (fid, spec) in specs {
|
||||||
let (who, principal) = match (&spec.requires_group, spec.public) {
|
let (who, principal) = match (&spec.requires_group, spec.public) {
|
||||||
(Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
(Some(g), _) => (
|
||||||
|
format!("group {g}"),
|
||||||
|
format!("principal in Group::{}", quote(g)),
|
||||||
|
),
|
||||||
(None, true) => ("anyone".to_string(), "principal".to_string()),
|
(None, true) => ("anyone".to_string(), "principal".to_string()),
|
||||||
// Neither: unreachable by design (fail closed).
|
// Neither: unreachable by design (fail closed).
|
||||||
(None, false) => continue,
|
(None, false) => continue,
|
||||||
@@ -279,20 +328,37 @@ impl Policy {
|
|||||||
.expect("generated policy text always parses");
|
.expect("generated policy text always parses");
|
||||||
policies.add(policy).expect("rule ids are unique");
|
policies.add(policy).expect("rule ids are unique");
|
||||||
}
|
}
|
||||||
let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant");
|
let schema = Schema::from_cedarschema_str(SCHEMA)
|
||||||
let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect();
|
.map(|(s, _)| s)
|
||||||
Policy { policies, schema, rules, text, patterns }
|
.expect("the schema is a constant");
|
||||||
|
let patterns = questions
|
||||||
|
.values()
|
||||||
|
.filter(|q| q.is_dynamic())
|
||||||
|
.map(|q| q.id.clone())
|
||||||
|
.collect();
|
||||||
|
Policy {
|
||||||
|
policies,
|
||||||
|
schema,
|
||||||
|
rules,
|
||||||
|
text,
|
||||||
|
patterns,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every generated rule type-checks against the schema. A rule the
|
/// Every generated rule type-checks against the schema. A rule the
|
||||||
/// schema cannot express is a compiler bug, and this is where it
|
/// schema cannot express is a compiler bug, and this is where it
|
||||||
/// shows.
|
/// shows.
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
|
let result =
|
||||||
|
Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
|
||||||
if result.validation_passed() {
|
if result.validation_passed() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(result.validation_errors().map(|e| e.to_string()).collect::<Vec<_>>().join("; "))
|
Err(result
|
||||||
|
.validation_errors()
|
||||||
|
.map(|e| e.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +383,10 @@ impl Policy {
|
|||||||
context: serde_json::Value,
|
context: serde_json::Value,
|
||||||
) -> Decision {
|
) -> Decision {
|
||||||
let uid = |ty: &str, id: &str| {
|
let uid = |ty: &str, id: &str| {
|
||||||
EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id))
|
EntityUid::from_type_name_and_id(
|
||||||
|
EntityTypeName::from_str(ty).expect("static type"),
|
||||||
|
EntityId::new(id),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let mut entities: Vec<Entity> = Vec::new();
|
let mut entities: Vec<Entity> = Vec::new();
|
||||||
let principal_uid = match principal {
|
let principal_uid = match principal {
|
||||||
@@ -332,8 +401,14 @@ impl Policy {
|
|||||||
}
|
}
|
||||||
Principal::Submitter { record, email } => {
|
Principal::Submitter { record, email } => {
|
||||||
let me = uid("Submitter", record);
|
let me = uid("Submitter", record);
|
||||||
let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]);
|
let attrs = HashMap::from([(
|
||||||
entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates"));
|
"email".to_string(),
|
||||||
|
RestrictedExpression::new_string(email.clone()),
|
||||||
|
)]);
|
||||||
|
entities.push(
|
||||||
|
Entity::new(me.clone(), attrs, HashSet::new())
|
||||||
|
.expect("a string attribute always evaluates"),
|
||||||
|
);
|
||||||
me
|
me
|
||||||
}
|
}
|
||||||
Principal::Service { name } => {
|
Principal::Service { name } => {
|
||||||
@@ -345,9 +420,14 @@ impl Policy {
|
|||||||
let resource_uid = match resource {
|
let resource_uid = match resource {
|
||||||
Resource::Page { id } => uid("Page", &self.page_entity(id)),
|
Resource::Page { id } => uid("Page", &self.page_entity(id)),
|
||||||
Resource::Bucket { name } => uid("Bucket", name),
|
Resource::Bucket { name } => uid("Bucket", name),
|
||||||
Resource::Feature { page, alternative, feature } => {
|
Resource::Feature {
|
||||||
uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page)))
|
page,
|
||||||
}
|
alternative,
|
||||||
|
feature,
|
||||||
|
} => uid(
|
||||||
|
"Feature",
|
||||||
|
&format!("{}|{alternative}|{feature}", self.page_entity(page)),
|
||||||
|
),
|
||||||
Resource::Group { name } => uid("Group", name),
|
Resource::Group { name } => uid("Group", name),
|
||||||
};
|
};
|
||||||
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
|
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
|
||||||
@@ -357,22 +437,32 @@ impl Policy {
|
|||||||
return Decision::default();
|
return Decision::default();
|
||||||
};
|
};
|
||||||
// An absent context is an empty one, not a malformed one.
|
// An absent context is an empty one, not a malformed one.
|
||||||
let context = if context.is_object() { context } else { serde_json::json!({}) };
|
let context = if context.is_object() {
|
||||||
|
context
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
let Ok(context) = Context::from_json_value(context, None) else {
|
let Ok(context) = Context::from_json_value(context, None) else {
|
||||||
return Decision::default();
|
return Decision::default();
|
||||||
};
|
};
|
||||||
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else {
|
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None)
|
||||||
|
else {
|
||||||
return Decision::default();
|
return Decision::default();
|
||||||
};
|
};
|
||||||
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
|
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
|
||||||
Decision {
|
Decision {
|
||||||
allow: response.decision() == cedar_policy::Decision::Allow,
|
allow: response.decision() == cedar_policy::Decision::Allow,
|
||||||
reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(),
|
reasons: response
|
||||||
|
.diagnostics()
|
||||||
|
.reason()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
|
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
|
||||||
self.decide(principal, action, resource, serde_json::json!({})).allow
|
self.decide(principal, action, resource, serde_json::json!({}))
|
||||||
|
.allow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,21 +563,50 @@ pub async fn serve(
|
|||||||
// A question that names a person and lists no groups is
|
// A question that names a person and lists no groups is
|
||||||
// asking portal what they belong to: their memberships,
|
// asking portal what they belong to: their memberships,
|
||||||
// matched on subject, phone number or address.
|
// matched on subject, phone number or address.
|
||||||
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
|
if let (
|
||||||
|
Principal::User {
|
||||||
|
sub,
|
||||||
|
groups,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
Some(state),
|
||||||
|
) = (&mut q.principal, &state)
|
||||||
|
{
|
||||||
if groups.is_empty() && sub != "anonymous" {
|
if groups.is_empty() && sub != "anonymous" {
|
||||||
let who = crate::memberships::Identity {
|
let who = crate::memberships::Identity {
|
||||||
sub: Some(sub.clone()),
|
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()))),
|
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()),
|
email: email.as_deref().map(|e| e.trim().to_lowercase()),
|
||||||
};
|
};
|
||||||
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
|
*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)
|
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}")] },
|
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());
|
let bytes = serde_json::to_vec(&decision)
|
||||||
|
.unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||||||
let _ = nats.publish(reply, bytes.into()).await;
|
let _ = nats.publish(reply, bytes.into()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,14 +632,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user(groups: &[&str]) -> Principal {
|
fn user(groups: &[&str]) -> Principal {
|
||||||
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
|
Principal::User {
|
||||||
|
sub: "u".into(),
|
||||||
|
groups: groups.iter().map(|g| g.to_string()).collect(),
|
||||||
|
phone: None,
|
||||||
|
email: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_generated_policy_validates_against_the_schema() {
|
fn the_generated_policy_validates_against_the_schema() {
|
||||||
let p = site();
|
let p = site();
|
||||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||||
assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited"));
|
assert!(p
|
||||||
|
.rules
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.id == "move:applicants:open>invited"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -530,7 +657,12 @@ mod tests {
|
|||||||
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
|
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
|
||||||
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
|
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
|
||||||
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
|
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
|
||||||
let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({}));
|
let d = p.decide(
|
||||||
|
&user(&["owners"]),
|
||||||
|
Action::Submit,
|
||||||
|
&page("/review"),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert!(d.allow);
|
assert!(d.allow);
|
||||||
assert_eq!(d.reasons, vec!["open:/review"]);
|
assert_eq!(d.reasons, vec!["open:/review"]);
|
||||||
// A concrete dynamic path resolves to its pattern's rule.
|
// A concrete dynamic path resolves to its pattern's rule.
|
||||||
@@ -541,8 +673,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reads_follow_requires_group_or_public() {
|
fn reads_follow_requires_group_or_public() {
|
||||||
let p = site();
|
let p = site();
|
||||||
let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() };
|
let desk = Resource::Feature {
|
||||||
let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() };
|
page: "/review".into(),
|
||||||
|
alternative: "Applicants".into(),
|
||||||
|
feature: "".into(),
|
||||||
|
};
|
||||||
|
let releases = Resource::Feature {
|
||||||
|
page: "/review".into(),
|
||||||
|
alternative: "Applicants".into(),
|
||||||
|
feature: "Releases".into(),
|
||||||
|
};
|
||||||
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
|
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
|
||||||
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
|
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
|
||||||
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
|
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
|
||||||
@@ -551,26 +691,88 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn transitions_are_exactly_the_desks_buttons() {
|
fn transitions_are_exactly_the_desks_buttons() {
|
||||||
let p = site();
|
let p = site();
|
||||||
let bucket = Resource::Bucket { name: "applicants".into() };
|
let bucket = Resource::Bucket {
|
||||||
|
name: "applicants".into(),
|
||||||
|
};
|
||||||
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
|
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
|
||||||
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
assert!(
|
||||||
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow);
|
p.decide(
|
||||||
|
&user(&["owners"]),
|
||||||
|
Action::Transition,
|
||||||
|
&bucket,
|
||||||
|
ctx("open", "invited")
|
||||||
|
)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
p.decide(
|
||||||
|
&user(&["owners"]),
|
||||||
|
Action::Transition,
|
||||||
|
&bucket,
|
||||||
|
ctx("invited", "active")
|
||||||
|
)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
// Right target, wrong starting state: no rule.
|
// Right target, wrong starting state: no rule.
|
||||||
assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow);
|
assert!(
|
||||||
assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
!p.decide(
|
||||||
|
&user(&["owners"]),
|
||||||
|
Action::Transition,
|
||||||
|
&bucket,
|
||||||
|
ctx("open", "active")
|
||||||
|
)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!p.decide(
|
||||||
|
&user(&["staff"]),
|
||||||
|
Action::Transition,
|
||||||
|
&bucket,
|
||||||
|
ctx("open", "invited")
|
||||||
|
)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
|
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
|
||||||
let p = site();
|
let p = site();
|
||||||
let bucket = Resource::Bucket { name: "subscribers".into() };
|
let bucket = Resource::Bucket {
|
||||||
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
name: "subscribers".into(),
|
||||||
let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
|
};
|
||||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
let me = Principal::Submitter {
|
||||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow);
|
record: "r1".into(),
|
||||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow);
|
email: "a@b.c".into(),
|
||||||
|
};
|
||||||
|
let ctx =
|
||||||
|
|stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
|
||||||
|
assert!(
|
||||||
|
p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c"))
|
||||||
|
.allow
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z"))
|
||||||
|
.allow
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!p.decide(
|
||||||
|
&me,
|
||||||
|
Action::SelfTransition,
|
||||||
|
&bucket,
|
||||||
|
json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })
|
||||||
|
)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
// A signed-in owner has no such rule: the link is the credential.
|
// A signed-in owner has no such rule: the link is the credential.
|
||||||
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
assert!(
|
||||||
|
!p.decide(
|
||||||
|
&user(&["owners"]),
|
||||||
|
Action::SelfTransition,
|
||||||
|
&bucket,
|
||||||
|
ctx("a@b.c")
|
||||||
|
)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The applicant written to for a missing drawing sends it from
|
// The applicant written to for a missing drawing sends it from
|
||||||
@@ -590,15 +792,52 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let p = Policy::from_content(&questions, &aggregates);
|
let p = Policy::from_content(&questions, &aggregates);
|
||||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||||
let bucket = Resource::Bucket { name: "cases".into() };
|
let bucket = Resource::Bucket {
|
||||||
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
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" });
|
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!(
|
||||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow);
|
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
|
// And only where it was said: the paperwork cannot be sent
|
||||||
// after the case is already withdrawn.
|
// after the case is already withdrawn.
|
||||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow);
|
assert!(
|
||||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow);
|
!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
|
// Omitted, `from` is the initial state alone - what every
|
||||||
@@ -606,8 +845,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() {
|
fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() {
|
||||||
let p = site();
|
let p = site();
|
||||||
assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed"));
|
assert!(p
|
||||||
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
|
.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
|
// "On arrival" is the bucket's own first state, not the word
|
||||||
@@ -633,20 +881,49 @@ mod tests {
|
|||||||
"the rule must start where the records do: {:?}",
|
"the rule must start where the records do: {:?}",
|
||||||
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
|
let me = Principal::Submitter {
|
||||||
let bucket = Resource::Bucket { name: "watchers".into() };
|
record: "r".into(),
|
||||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
|
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]
|
#[test]
|
||||||
fn invites_and_automation() {
|
fn invites_and_automation() {
|
||||||
let p = site();
|
let p = site();
|
||||||
let owners = Resource::Group { name: "owners".into() };
|
let owners = Resource::Group {
|
||||||
|
name: "owners".into(),
|
||||||
|
};
|
||||||
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
|
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
|
||||||
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
|
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
|
||||||
let bucket = Resource::Bucket { name: "applicants".into() };
|
let bucket = Resource::Bucket {
|
||||||
assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket));
|
name: "applicants".into(),
|
||||||
assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket));
|
};
|
||||||
|
assert!(p.allows(
|
||||||
|
&Principal::Service {
|
||||||
|
name: "automation".into()
|
||||||
|
},
|
||||||
|
Action::AutomationRead,
|
||||||
|
&bucket
|
||||||
|
));
|
||||||
|
assert!(!p.allows(
|
||||||
|
&Principal::Service {
|
||||||
|
name: "someone".into()
|
||||||
|
},
|
||||||
|
Action::AutomationRead,
|
||||||
|
&bucket
|
||||||
|
));
|
||||||
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
|
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,12 +934,18 @@ mod tests {
|
|||||||
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
|
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
assert!(
|
||||||
|
p.decide(&q.principal, q.action, &q.resource, q.context)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
let q: Query = serde_json::from_str(
|
let q: Query = serde_json::from_str(
|
||||||
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
|
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
assert!(
|
||||||
|
p.decide(&q.principal, q.action, &q.resource, q.context)
|
||||||
|
.allow
|
||||||
|
);
|
||||||
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
|
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -78,7 +78,9 @@ fn is_zero(n: &u32) -> bool {
|
|||||||
|
|
||||||
impl LeavesSpec {
|
impl LeavesSpec {
|
||||||
pub fn millis(&self) -> i64 {
|
pub fn millis(&self) -> i64 {
|
||||||
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
|
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes))
|
||||||
|
* 60
|
||||||
|
* 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +202,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
let mut event_for_state = HashMap::new();
|
let mut event_for_state = HashMap::new();
|
||||||
let mut state_for_event = HashMap::new();
|
let mut state_for_event = HashMap::new();
|
||||||
for (state, raw_state) in &raw_schema.states {
|
for (state, raw_state) in &raw_schema.states {
|
||||||
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone())
|
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone()) {
|
||||||
{
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"aggregates.yaml: bucket {bucket:?}: event {:?} used by both state {:?} and {:?}",
|
"aggregates.yaml: bucket {bucket:?}: event {:?} used by both state {:?} and {:?}",
|
||||||
raw_state.event, existing, state
|
raw_state.event, existing, state
|
||||||
@@ -238,7 +239,9 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
}
|
}
|
||||||
if let Some(group) = raw_state.grants {
|
if let Some(group) = raw_state.grants {
|
||||||
if group.trim().is_empty() {
|
if group.trim().is_empty() {
|
||||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
|
anyhow::bail!(
|
||||||
|
"aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
grants_for_state.insert(state.clone(), group);
|
grants_for_state.insert(state.clone(), group);
|
||||||
}
|
}
|
||||||
@@ -248,7 +251,11 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
}
|
}
|
||||||
// A clock may only make a move a person could have
|
// A clock may only make a move a person could have
|
||||||
// made, so it is held to the same graph.
|
// made, so it is held to the same graph.
|
||||||
if !raw_schema.transitions.get(&state).is_some_and(|tos| tos.contains(&after.to)) {
|
if !raw_schema
|
||||||
|
.transitions
|
||||||
|
.get(&state)
|
||||||
|
.is_some_and(|tos| tos.contains(&after.to))
|
||||||
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it",
|
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it",
|
||||||
after.to
|
after.to
|
||||||
@@ -261,7 +268,10 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
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();
|
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)) {
|
if !mail.to_submitter()
|
||||||
|
&& !through_a_record
|
||||||
|
&& !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace))
|
||||||
|
{
|
||||||
anyhow::bail!(
|
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 {:?}",
|
"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.to
|
||||||
@@ -334,7 +344,10 @@ pub fn replay(schema: &AggregateSchema, id: &str, events: &[Event]) -> Option<Ag
|
|||||||
pub enum TransitionError {
|
pub enum TransitionError {
|
||||||
/// No creation event found for this id - nothing to transition.
|
/// No creation event found for this id - nothing to transition.
|
||||||
UnknownAggregate,
|
UnknownAggregate,
|
||||||
DisallowedTransition { from: String, to: String },
|
DisallowedTransition {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
},
|
||||||
/// A concurrent writer advanced this aggregate past the sequence
|
/// A concurrent writer advanced this aggregate past the sequence
|
||||||
/// this call read (see `events::store::append_event`'s CAS).
|
/// this call read (see `events::store::append_event`'s CAS).
|
||||||
Conflict,
|
Conflict,
|
||||||
|
|||||||
+28
-6
@@ -37,7 +37,11 @@ pub async fn run(state: AppState) {
|
|||||||
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let questions = state.questions.load();
|
let questions = state.questions.load();
|
||||||
let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect();
|
let events: Vec<_> = questions
|
||||||
|
.values()
|
||||||
|
.filter(|q| q.event.is_some())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
if events.is_empty() {
|
if events.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -100,7 +104,9 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
let mut refreshed = answer.clone();
|
let mut refreshed = answer.clone();
|
||||||
refreshed.responses = responses.clone();
|
refreshed.responses = responses.clone();
|
||||||
if let Some(store) = &store {
|
if let Some(store) = &store {
|
||||||
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
|
store
|
||||||
|
.put(&id, serde_json::to_vec(&refreshed)?.into())
|
||||||
|
.await?;
|
||||||
tracing::info!(question = %q.id, "event record refreshed from content");
|
tracing::info!(question = %q.id, "event record refreshed from content");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,15 +114,31 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
if answer.state == schema.initial && now_ms >= ends {
|
if answer.state == schema.initial && now_ms >= ends {
|
||||||
let target = "awaiting_summary";
|
let target = "awaiting_summary";
|
||||||
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
|
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
|
||||||
match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await {
|
match crate::aggregates::transition(
|
||||||
|
&state.jetstream,
|
||||||
|
schema,
|
||||||
|
&id,
|
||||||
|
target,
|
||||||
|
payload.clone(),
|
||||||
|
now_ms,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
|
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
|
||||||
crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms)
|
crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms)
|
crate::aggregates::transition(
|
||||||
.await
|
&state.jetstream,
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
schema,
|
||||||
|
&id,
|
||||||
|
target,
|
||||||
|
payload.clone(),
|
||||||
|
now_ms,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
}
|
}
|
||||||
Err(e) => return Err(anyhow::anyhow!("{e}")),
|
Err(e) => return Err(anyhow::anyhow!("{e}")),
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-25
@@ -38,6 +38,11 @@ pub struct Answer {
|
|||||||
/// not see before.
|
/// not see before.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub notes: Vec<Note>,
|
pub notes: Vec<Note>,
|
||||||
|
/// Set by a desk listing, never stored: the record is in a final
|
||||||
|
/// state (one with no move out). A desk shows the newest few of
|
||||||
|
/// those and offers the rest on request (`resource::cap_finished`).
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One reply on a record. It lives here rather than with the mail
|
/// One reply on a record. It lives here rather than with the mail
|
||||||
@@ -97,6 +102,7 @@ pub async fn store_answer(
|
|||||||
storyline,
|
storyline,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
};
|
};
|
||||||
let data = serde_json::to_vec(&answer)?;
|
let data = serde_json::to_vec(&answer)?;
|
||||||
@@ -107,7 +113,9 @@ pub async fn store_answer(
|
|||||||
// submission the NATS notification event has already recorded"
|
// submission the NATS notification event has already recorded"
|
||||||
// policy as the KV write above.
|
// policy as the KV write above.
|
||||||
if let Some(schema) = aggregates.get(bucket) {
|
if let Some(schema) = aggregates.get(bucket) {
|
||||||
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
|
if let Err(e) =
|
||||||
|
crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await
|
||||||
|
{
|
||||||
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
|
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,17 +129,27 @@ pub const OWNER_FIELD: &str = "by";
|
|||||||
|
|
||||||
/// Every record in `bucket` that belongs to `username`, newest first.
|
/// Every record in `bucket` that belongs to `username`, newest first.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
|
pub async fn own_records(
|
||||||
|
js: &async_nats::jetstream::Context,
|
||||||
|
bucket: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> Vec<Answer> {
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
|
let Ok(store) = js.get_key_value(bucket).await else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let keys: Vec<String> = match store.keys().await {
|
let keys: Vec<String> = match store.keys().await {
|
||||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
let mut own = Vec::new();
|
let mut own = Vec::new();
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
let Ok(Some(bytes)) = store.get(&key).await else {
|
||||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
|
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
|
||||||
own.push(answer);
|
own.push(answer);
|
||||||
}
|
}
|
||||||
@@ -145,9 +163,16 @@ pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, user
|
|||||||
/// it (the event log keeps them). Run after every store, so two sends
|
/// it (the event log keeps them). Run after every store, so two sends
|
||||||
/// racing each other - a double click - both settle on the same one.
|
/// racing each other - a double click - both settle on the same one.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
|
pub async fn keep_newest_own(
|
||||||
|
js: &async_nats::jetstream::Context,
|
||||||
|
bucket: &str,
|
||||||
|
initial: &str,
|
||||||
|
username: &str,
|
||||||
|
) {
|
||||||
let own = own_records(js, bucket, username).await;
|
let own = own_records(js, bucket, username).await;
|
||||||
let Ok(store) = js.get_key_value(bucket).await else { return };
|
let Ok(store) = js.get_key_value(bucket).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
for older in own.iter().filter(|a| a.state == initial).skip(1) {
|
for older in own.iter().filter(|a| a.state == initial).skip(1) {
|
||||||
if let Err(e) = store.delete(&older.id).await {
|
if let Err(e) = store.delete(&older.id).await {
|
||||||
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
|
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
|
||||||
@@ -161,16 +186,39 @@ pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str,
|
|||||||
/// the address a list would open it from, and may be left empty to
|
/// the address a list would open it from, and may be left empty to
|
||||||
/// keep it.
|
/// keep it.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
|
pub async fn fill_own(
|
||||||
|
js: &async_nats::jetstream::Context,
|
||||||
|
question: &mut crate::content::Question,
|
||||||
|
user: Option<&crate::auth::User>,
|
||||||
|
) {
|
||||||
let Some(user) = user else { return };
|
let Some(user) = user else { return };
|
||||||
for alt in &mut question.alternatives {
|
for alt in &mut question.alternatives {
|
||||||
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
|
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else {
|
||||||
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
|
continue;
|
||||||
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
|
};
|
||||||
|
let Some(mine) = own_records(js, &bucket, &user.username)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for req in alt
|
||||||
|
.features
|
||||||
|
.iter_mut()
|
||||||
|
.flat_map(|f| f.requirements.iter_mut())
|
||||||
|
{
|
||||||
if req.kind == "hidden" {
|
if req.kind == "hidden" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
|
let Some(value) = mine
|
||||||
|
.responses
|
||||||
|
.get(&req.name)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if req.kind == "file" {
|
if req.kind == "file" {
|
||||||
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
|
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
|
||||||
req.optional = true;
|
req.optional = true;
|
||||||
@@ -211,7 +259,12 @@ pub async fn transition_answers(
|
|||||||
.load()
|
.load()
|
||||||
.at(storyline)
|
.at(storyline)
|
||||||
.get(&question_id)
|
.get(&question_id)
|
||||||
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
.and_then(|q| {
|
||||||
|
q.alternatives
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == alternative)
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
||||||
|
|
||||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||||
@@ -327,7 +380,9 @@ async fn apply_transition(
|
|||||||
&state.access.load(),
|
&state.access.load(),
|
||||||
&crate::access::Principal::from_user(Some(user)),
|
&crate::access::Principal::from_user(Some(user)),
|
||||||
crate::access::Action::Transition,
|
crate::access::Action::Transition,
|
||||||
&crate::access::Resource::Bucket { name: bucket.to_string() },
|
&crate::access::Resource::Bucket {
|
||||||
|
name: bucket.to_string(),
|
||||||
|
},
|
||||||
serde_json::json!({ "from": answer.state, "to": item.to }),
|
serde_json::json!({ "from": answer.state, "to": item.to }),
|
||||||
);
|
);
|
||||||
if !decision.allow {
|
if !decision.allow {
|
||||||
@@ -364,7 +419,15 @@ async fn apply_transition(
|
|||||||
.put(&item.item_id, data.into())
|
.put(&item.item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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::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;
|
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.
|
// An expired invite sent again: the move back is the asking.
|
||||||
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
||||||
@@ -374,7 +437,12 @@ async fn apply_transition(
|
|||||||
// The decision extends the DAG as a child of the answer's own
|
// The decision extends the DAG as a child of the answer's own
|
||||||
// submission hash, published like any other answer.
|
// submission hash, published like any other answer.
|
||||||
let parent_hashes = vec![item.item_id.clone()];
|
let parent_hashes = vec![item.item_id.clone()];
|
||||||
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
let chain_hash = hash_node(
|
||||||
|
question_id,
|
||||||
|
&parent_hashes,
|
||||||
|
&decision_payload_for_event,
|
||||||
|
decided_ms,
|
||||||
|
);
|
||||||
let event = AnswerSubmitted {
|
let event = AnswerSubmitted {
|
||||||
chain_hash,
|
chain_hash,
|
||||||
parent_hashes,
|
parent_hashes,
|
||||||
@@ -441,13 +509,22 @@ pub async fn self_transition_answer(
|
|||||||
// policy compares them and knows the one move a submitter may make.
|
// policy compares them and knows the one move a submitter may make.
|
||||||
// Refusal reads "not found" so the endpoint cannot be used to probe
|
// Refusal reads "not found" so the endpoint cannot be used to probe
|
||||||
// which records or emails exist.
|
// which records or emails exist.
|
||||||
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
|
let stored_email = answer
|
||||||
|
.responses
|
||||||
|
.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
let decision = crate::access::decide_and_publish(
|
let decision = crate::access::decide_and_publish(
|
||||||
&state.nats,
|
&state.nats,
|
||||||
&state.access.load(),
|
&state.access.load(),
|
||||||
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
|
&crate::access::Principal::Submitter {
|
||||||
|
record: item_id.clone(),
|
||||||
|
email: email.clone(),
|
||||||
|
},
|
||||||
crate::access::Action::SelfTransition,
|
crate::access::Action::SelfTransition,
|
||||||
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
|
&crate::access::Resource::Bucket {
|
||||||
|
name: self_transition.bucket.clone(),
|
||||||
|
},
|
||||||
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
||||||
);
|
);
|
||||||
if !decision.allow {
|
if !decision.allow {
|
||||||
@@ -462,8 +539,15 @@ pub async fn self_transition_answer(
|
|||||||
.get(&self_transition.bucket)
|
.get(&self_transition.bucket)
|
||||||
.map(|s| s.initial.clone())
|
.map(|s| s.initial.clone())
|
||||||
.unwrap_or_else(|| OPEN_STATE.to_string());
|
.unwrap_or_else(|| OPEN_STATE.to_string());
|
||||||
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
let in_reach = self_transition
|
||||||
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
.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();
|
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||||
@@ -493,11 +577,31 @@ pub async fn self_transition_answer(
|
|||||||
.put(&item_id, data.into())
|
.put(&item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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::mail::on_state(
|
||||||
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
&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 parent_hashes = vec![item_id.clone()];
|
||||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
let chain_hash = hash_node(
|
||||||
|
&question_id,
|
||||||
|
&parent_hashes,
|
||||||
|
&decision_payload_for_event,
|
||||||
|
decided_ms,
|
||||||
|
);
|
||||||
let event = AnswerSubmitted {
|
let event = AnswerSubmitted {
|
||||||
chain_hash,
|
chain_hash,
|
||||||
parent_hashes,
|
parent_hashes,
|
||||||
@@ -537,6 +641,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
},
|
},
|
||||||
Answer {
|
Answer {
|
||||||
id: "def456".to_string(),
|
id: "def456".to_string(),
|
||||||
@@ -549,10 +654,14 @@ mod tests {
|
|||||||
decided_ms: Some(1_700_000_002_000),
|
decided_ms: Some(1_700_000_002_000),
|
||||||
decided_by: Some("bl".to_string()),
|
decided_by: Some("bl".to_string()),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let value = serde_json::Value::Array(
|
let value = serde_json::Value::Array(
|
||||||
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
|
answers
|
||||||
|
.iter()
|
||||||
|
.map(|a| serde_json::to_value(a).unwrap())
|
||||||
|
.collect(),
|
||||||
);
|
);
|
||||||
let round_tripped: Vec<Answer> =
|
let round_tripped: Vec<Answer> =
|
||||||
serde_json::from_value(value).expect("Answer list round-trips");
|
serde_json::from_value(value).expect("Answer list round-trips");
|
||||||
|
|||||||
+679
-129
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -53,7 +53,9 @@ pub async fn record_node(
|
|||||||
alternative: Some(alternative.to_string()),
|
alternative: Some(alternative.to_string()),
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
};
|
};
|
||||||
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
|
store
|
||||||
|
.put(chain_hash, serde_json::to_vec(&node)?.into())
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-12
@@ -9,7 +9,10 @@ use super::*;
|
|||||||
/// config; a file that exists but doesn't parse or validate is a real
|
/// config; a file that exists but doesn't parse or validate is a real
|
||||||
/// error, surfaced at boot rather than papered over.
|
/// error, surfaced at boot rather than papered over.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Storylines<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 (owner, repo) = parse_owner_repo(repo_url)?;
|
||||||
let api_base = gitea_api_base(repo_url)?;
|
let api_base = gitea_api_base(repo_url)?;
|
||||||
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
||||||
@@ -24,8 +27,8 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
|
|||||||
let arity = super::variants::arity(&files)?;
|
let arity = super::variants::arity(&files)?;
|
||||||
let mut each = Vec::with_capacity(arity);
|
let mut each = Vec::with_capacity(arity);
|
||||||
for n in 0..arity {
|
for n in 0..arity {
|
||||||
let site: SiteConfig =
|
let site: SiteConfig = serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1)
|
||||||
serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
||||||
site.validate()?;
|
site.validate()?;
|
||||||
each.push(site);
|
each.push(site);
|
||||||
}
|
}
|
||||||
@@ -40,7 +43,10 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
|
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
|
||||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||||
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
|
Ok(format!(
|
||||||
|
"{}/api/v1/repos/{owner}/{repo}/raw",
|
||||||
|
gitea_api_base(repo_url)?
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
@@ -114,7 +120,8 @@ pub(crate) async fn fetch_gitea_file(
|
|||||||
pub async fn load_aggregates_from_gitea(
|
pub async fn load_aggregates_from_gitea(
|
||||||
repo_url: &str,
|
repo_url: &str,
|
||||||
branch: &str,
|
branch: &str,
|
||||||
) -> anyhow::Result<Storylines<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 (owner, repo) = parse_owner_repo(repo_url)?;
|
||||||
let api_base = gitea_api_base(repo_url)?;
|
let api_base = gitea_api_base(repo_url)?;
|
||||||
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
||||||
@@ -123,7 +130,10 @@ pub async fn load_aggregates_from_gitea(
|
|||||||
let files = vec![("aggregates.yaml".to_string(), raw)];
|
let files = vec![("aggregates.yaml".to_string(), raw)];
|
||||||
let arity = super::variants::arity(&files)?;
|
let arity = super::variants::arity(&files)?;
|
||||||
let each = (0..arity)
|
let each = (0..arity)
|
||||||
.map(|n| crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1).map(with_builtin_aggregates))
|
.map(|n| {
|
||||||
|
crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1)
|
||||||
|
.map(with_builtin_aggregates)
|
||||||
|
})
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
Ok(Storylines { each })
|
Ok(Storylines { each })
|
||||||
}
|
}
|
||||||
@@ -183,7 +193,11 @@ pub async fn load_questions_from_gitea(
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
|
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
|
||||||
let arity = questions.max(aggregates).max(site);
|
let arity = questions.max(aggregates).max(site);
|
||||||
for (what, n) in [("questions", questions), ("aggregates.yaml", aggregates), ("site.yaml", site)] {
|
for (what, n) in [
|
||||||
|
("questions", questions),
|
||||||
|
("aggregates.yaml", aggregates),
|
||||||
|
("site.yaml", site),
|
||||||
|
] {
|
||||||
if n != 1 && n != arity {
|
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");
|
anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
|
||||||
}
|
}
|
||||||
@@ -219,9 +233,13 @@ pub async fn watch_for_reload(
|
|||||||
repo_url: String,
|
repo_url: String,
|
||||||
branch: String,
|
branch: String,
|
||||||
subdir: String,
|
subdir: String,
|
||||||
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
|
questions: std::sync::Arc<
|
||||||
|
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>,
|
||||||
|
>,
|
||||||
aggregates: std::sync::Arc<
|
aggregates: std::sync::Arc<
|
||||||
arc_swap::ArcSwap<Storylines<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<Storylines<SiteConfig>>>,
|
site: std::sync::Arc<arc_swap::ArcSwap<Storylines<SiteConfig>>>,
|
||||||
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
|
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
|
||||||
@@ -255,14 +273,23 @@ pub async fn watch_for_reload(
|
|||||||
};
|
};
|
||||||
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
||||||
Ok(loaded) => {
|
Ok(loaded) => {
|
||||||
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) {
|
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");
|
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let count = loaded.len();
|
let count = loaded.len();
|
||||||
// The policy is compiled from the content it gates, so
|
// The policy is compiled from the content it gates, so
|
||||||
// it swaps in the same breath.
|
// it swaps in the same breath.
|
||||||
access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates)));
|
access.store(std::sync::Arc::new(crate::access::Policy::from_content(
|
||||||
|
&loaded,
|
||||||
|
&loaded_aggregates,
|
||||||
|
)));
|
||||||
questions.store(std::sync::Arc::new(loaded));
|
questions.store(std::sync::Arc::new(loaded));
|
||||||
aggregates.store(std::sync::Arc::new(loaded_aggregates));
|
aggregates.store(std::sync::Arc::new(loaded_aggregates));
|
||||||
site.store(std::sync::Arc::new(loaded_site));
|
site.store(std::sync::Arc::new(loaded_site));
|
||||||
@@ -275,4 +302,3 @@ pub async fn watch_for_reload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-15
@@ -49,15 +49,56 @@ pub struct GiteaRepoQuery {
|
|||||||
pub async fn site_asset_handler(
|
pub async fn site_asset_handler(
|
||||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||||
axum::extract::Path(path): axum::extract::Path<String>,
|
axum::extract::Path(path): axum::extract::Path<String>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
serve_repo_file(&state, &path, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /.well-known/{*path}` - the content repo overlaid on the one part
|
||||||
|
/// of the URL space that can never collide with a question: RFC 8615
|
||||||
|
/// reserves `/.well-known/`, and a question's id is derived from a file
|
||||||
|
/// name under `questions/`, which cannot begin with a dot. A repo ships
|
||||||
|
/// `.well-known/security.txt`, `.well-known/assetlinks.json`,
|
||||||
|
/// `.well-known/matrix/server` and so on at the path they are served at,
|
||||||
|
/// and a miss is a real 404 rather than the app shell answering 200 with
|
||||||
|
/// an HTML page - which is what a client probing for one of these would
|
||||||
|
/// otherwise have to parse.
|
||||||
|
///
|
||||||
|
/// Paths Caddy claims in front of portal (mail autoconfiguration, the
|
||||||
|
/// CalDAV and CardDAV redirects) never reach here.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn well_known_handler(
|
||||||
|
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||||
|
axum::extract::Path(path): axum::extract::Path<String>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
serve_repo_file(&state, &format!(".well-known/{path}"), true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proxies one repo-relative file from the content repo's default branch.
|
||||||
|
/// `well_known` only changes what an extensionless file is typed as: under
|
||||||
|
/// `/.well-known/` those are JSON by convention (`matrix/server`,
|
||||||
|
/// `nodeinfo`), while elsewhere a file with no extension is an unknown.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
async fn serve_repo_file(
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
path: &str,
|
||||||
|
well_known: bool,
|
||||||
) -> axum::response::Response {
|
) -> axum::response::Response {
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
if !is_safe_site_path(&path) {
|
if !is_safe_site_path(path) {
|
||||||
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
|
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
|
||||||
}
|
}
|
||||||
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
|
let url = format!(
|
||||||
let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) {
|
"{}/{path}?ref={}",
|
||||||
|
state.content_raw_base, state.content_branch
|
||||||
|
);
|
||||||
|
let resp = match crate::http::client()
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.and_then(|r| r.error_for_status())
|
||||||
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("site asset {path}: {e}");
|
tracing::warn!("site asset {path}: {e}");
|
||||||
@@ -79,12 +120,26 @@ pub async fn site_asset_handler(
|
|||||||
Some("avif") => "image/avif",
|
Some("avif") => "image/avif",
|
||||||
Some("ico") => "image/x-icon",
|
Some("ico") => "image/x-icon",
|
||||||
Some("woff2") => "font/woff2",
|
Some("woff2") => "font/woff2",
|
||||||
|
Some("txt") => "text/plain; charset=utf-8",
|
||||||
|
Some("xml") => "application/xml; charset=utf-8",
|
||||||
|
Some("webmanifest") => "application/manifest+json",
|
||||||
|
// An Apple configuration profile only offers to install when it
|
||||||
|
// arrives under this type; anything else is just a download.
|
||||||
|
Some("mobileconfig") => "application/x-apple-aspen-config",
|
||||||
|
// A file with no extension at all: rsplit yields the whole name.
|
||||||
|
_ if well_known && !path.rsplit('/').next().unwrap_or_default().contains('.') => {
|
||||||
|
"application/json; charset=utf-8"
|
||||||
|
}
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
};
|
};
|
||||||
// Photographs are large and do not change under a name, so a
|
// Photographs are large and do not change under a name, so a
|
||||||
// browser may keep them for an hour; everything else revalidates,
|
// browser may keep them for an hour; everything else revalidates,
|
||||||
// so a content push shows on reload.
|
// so a content push shows on reload.
|
||||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
|
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") {
|
||||||
|
"public, max-age=3600"
|
||||||
|
} else {
|
||||||
|
"no-cache"
|
||||||
|
};
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
||||||
bytes,
|
bytes,
|
||||||
@@ -114,14 +169,22 @@ pub async fn gitea_repo_handler(
|
|||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||||
};
|
};
|
||||||
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
|
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
|
||||||
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
|
return Err((
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
"invalid owner/repo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let GiteaRepoQuery { owner, repo } = query;
|
let GiteaRepoQuery { owner, repo } = query;
|
||||||
|
|
||||||
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
|
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
|
||||||
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
|
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::BAD_GATEWAY,
|
||||||
|
format!("fetching {api_url}: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||||
(
|
(
|
||||||
axum::http::StatusCode::BAD_GATEWAY,
|
axum::http::StatusCode::BAD_GATEWAY,
|
||||||
@@ -168,27 +231,38 @@ pub async fn automation_kv_handler(
|
|||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
if expected.is_empty() || presented != expected {
|
if expected.is_empty() || presented != expected {
|
||||||
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
|
return Err((
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"unauthorized".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// The token authenticates the automation service; what it may read
|
// The token authenticates the automation service; what it may read
|
||||||
// is the policy's to say, like every other principal.
|
// is the policy's to say, like every other principal.
|
||||||
let decision = crate::access::decide_and_publish(
|
let decision = crate::access::decide_and_publish(
|
||||||
&state.nats,
|
&state.nats,
|
||||||
&state.access.load(),
|
&state.access.load(),
|
||||||
&crate::access::Principal::Service { name: "automation".to_string() },
|
&crate::access::Principal::Service {
|
||||||
|
name: "automation".to_string(),
|
||||||
|
},
|
||||||
crate::access::Action::AutomationRead,
|
crate::access::Action::AutomationRead,
|
||||||
&crate::access::Resource::Bucket { name: bucket.clone() },
|
&crate::access::Resource::Bucket {
|
||||||
|
name: bucket.clone(),
|
||||||
|
},
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
);
|
);
|
||||||
if !decision.allow {
|
if !decision.allow {
|
||||||
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
|
return Err((
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
"not authorized".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let store = state
|
let store = state.jetstream.get_key_value(&bucket).await.map_err(|e| {
|
||||||
.jetstream
|
(
|
||||||
.get_key_value(&bucket)
|
axum::http::StatusCode::BAD_GATEWAY,
|
||||||
.await
|
format!("bucket unavailable: {e}"),
|
||||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
let keys: Vec<String> = store
|
let keys: Vec<String> = store
|
||||||
@@ -209,3 +283,29 @@ pub async fn automation_kv_handler(
|
|||||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The overlay's whole safety argument: `/.well-known/` is reserved by
|
||||||
|
/// RFC 8615 and a question's id comes from a file under `questions/`, so
|
||||||
|
/// a repo file served at the site root cannot shadow a page - and the
|
||||||
|
/// path it is fetched by still cannot climb out of the repo.
|
||||||
|
#[test]
|
||||||
|
fn well_known_paths_stay_inside_the_repo() {
|
||||||
|
for path in [
|
||||||
|
".well-known/security.txt",
|
||||||
|
".well-known/matrix/server",
|
||||||
|
".well-known/assetlinks.json",
|
||||||
|
] {
|
||||||
|
assert!(is_safe_site_path(path), "{path} should be servable");
|
||||||
|
}
|
||||||
|
for path in [
|
||||||
|
".well-known/../questions/index.yaml",
|
||||||
|
".well-known//escape",
|
||||||
|
"/.well-known/security.txt",
|
||||||
|
] {
|
||||||
|
assert!(!is_safe_site_path(path), "{path} should be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
-7
@@ -118,7 +118,6 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
|
|||||||
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Markdown that a person wrote into a record - a bio on a card - as
|
/// Markdown that a person wrote into a record - a bio on a card - as
|
||||||
/// opposed to the site's own content. Paragraphs, line breaks as they
|
/// opposed to the site's own content. Paragraphs, line breaks as they
|
||||||
/// were typed, lists, emphasis and links; no images (a picture has its
|
/// were typed, lists, emphasis and links; no images (a picture has its
|
||||||
@@ -147,7 +146,9 @@ pub fn render_written_markdown(text: &str) -> String {
|
|||||||
// What someone typed on a new line is on a new line.
|
// What someone typed on a new line is on a new line.
|
||||||
Event::SoftBreak => Some(Event::HardBreak),
|
Event::SoftBreak => Some(Event::HardBreak),
|
||||||
Event::Start(Tag::Link { dest_url, .. })
|
Event::Start(Tag::Link { dest_url, .. })
|
||||||
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
|
if !(dest_url.starts_with("https://")
|
||||||
|
|| dest_url.starts_with("mailto:")
|
||||||
|
|| dest_url.starts_with("tel:")) =>
|
||||||
{
|
{
|
||||||
in_dropped_link += 1;
|
in_dropped_link += 1;
|
||||||
None
|
None
|
||||||
@@ -160,7 +161,8 @@ pub fn render_written_markdown(text: &str) -> String {
|
|||||||
});
|
});
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
html::push_html(&mut out, filtered);
|
html::push_html(&mut out, filtered);
|
||||||
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
|
out.trim()
|
||||||
|
.replace("<a href=", "<a rel=\"nofollow ugc\" href=")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -171,14 +173,25 @@ mod written_tests {
|
|||||||
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
|
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
|
||||||
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
|
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
|
||||||
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
|
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
|
||||||
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
|
assert!(md("**Sivilingeniør**, *NTNU*")
|
||||||
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
|
.contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
|
||||||
|
assert_eq!(
|
||||||
|
md("[Westra](https://westra.no)"),
|
||||||
|
"<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>"
|
||||||
|
);
|
||||||
// Everything that would put something else in the page is gone.
|
// Everything that would put something else in the page is gone.
|
||||||
assert_eq!(md("<script>alert(1)</script>"), "");
|
assert_eq!(md("<script>alert(1)</script>"), "");
|
||||||
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
|
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
|
||||||
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
|
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
|
||||||
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
|
assert_eq!(
|
||||||
assert_eq!(md("before  after"), "<p>before after</p>");
|
md("[home](/kontor)"),
|
||||||
|
"<p>home</p>",
|
||||||
|
"a site path is the site's to link, not a bio's"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
md("before  after"),
|
||||||
|
"<p>before after</p>"
|
||||||
|
);
|
||||||
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+343
-83
@@ -13,19 +13,19 @@ use serde::{Deserialize, Serialize};
|
|||||||
// Loading is server-only; the rest is shared with the browser build.
|
// Loading is server-only; the rest is shared with the browser build.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
mod gitea;
|
mod gitea;
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
mod variants;
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod validate;
|
mod validate;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
mod variants;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub use gitea::*;
|
pub use gitea::*;
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub use variants::*;
|
|
||||||
pub use handlers::*;
|
pub use handlers::*;
|
||||||
pub use markdown::*;
|
pub use markdown::*;
|
||||||
pub use validate::*;
|
pub use validate::*;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use variants::*;
|
||||||
|
|
||||||
/// One page: a prompt plus the paths on from it. The `questions/`
|
/// One page: a prompt plus the paths on from it. The `questions/`
|
||||||
/// directory tree IS the URL tree (`index.yaml` names its directory,
|
/// directory tree IS the URL tree (`index.yaml` names its directory,
|
||||||
@@ -129,7 +129,10 @@ impl Place {
|
|||||||
|
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) {
|
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));
|
return Err(format!(
|
||||||
|
"lat {} / lon {} is not a point on Earth",
|
||||||
|
self.lat, self.lon
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !(0.0..=22.0).contains(&self.zoom) {
|
if !(0.0..=22.0).contains(&self.zoom) {
|
||||||
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
|
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
|
||||||
@@ -158,13 +161,23 @@ pub fn parse_duration_ms(text: &str) -> Result<i64, String> {
|
|||||||
let mut total: i64 = 0;
|
let mut total: i64 = 0;
|
||||||
let mut seen = false;
|
let mut seen = false;
|
||||||
for token in text.split_whitespace() {
|
for token in text.split_whitespace() {
|
||||||
let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len());
|
let (num, unit) = token.split_at(
|
||||||
let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
|
token
|
||||||
|
.trim_end_matches(|c: char| c.is_ascii_alphabetic())
|
||||||
|
.len(),
|
||||||
|
);
|
||||||
|
let n: i64 = num
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
|
||||||
let per = match unit {
|
let per = match unit {
|
||||||
"m" => 60_000,
|
"m" => 60_000,
|
||||||
"h" => 3_600_000,
|
"h" => 3_600_000,
|
||||||
"d" => 86_400_000,
|
"d" => 86_400_000,
|
||||||
_ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")),
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"bad duration {text:?}: unit {unit:?} (use m, h, d)"
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
total += n * per;
|
total += n * per;
|
||||||
seen = true;
|
seen = true;
|
||||||
@@ -228,7 +241,9 @@ pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>)
|
|||||||
for alt in &mut question.alternatives {
|
for alt in &mut question.alternatives {
|
||||||
for feature in &mut alt.features {
|
for feature in &mut alt.features {
|
||||||
for req in &mut feature.requirements {
|
for req in &mut feature.requirements {
|
||||||
let Some(value) = req.value.as_deref() else { continue };
|
let Some(value) = req.value.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let filled = match value {
|
let filled = match value {
|
||||||
"{user.email}" => user.and_then(|u| u.email.clone()),
|
"{user.email}" => user.and_then(|u| u.email.clone()),
|
||||||
"{user.name}" => user.map(|u| u.display_name.clone()),
|
"{user.name}" => user.map(|u| u.display_name.clone()),
|
||||||
@@ -274,7 +289,13 @@ pub fn template_action(action: &str, responses: &serde_json::Value) -> Option<St
|
|||||||
// A URL segment: keep it to what a segment can carry.
|
// A URL segment: keep it to what a segment can carry.
|
||||||
let safe: String = scalar
|
let safe: String = scalar
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || "-_.".contains(c) {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.push_str(&safe);
|
out.push_str(&safe);
|
||||||
rest = &rest[end + 1..];
|
rest = &rest[end + 1..];
|
||||||
@@ -519,6 +540,18 @@ pub struct ResourceSpec {
|
|||||||
/// it where the silence needs a voice ("Nothing released yet").
|
/// it where the silence needs a voice ("Nothing released yet").
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub empty: Option<String>,
|
pub empty: Option<String>,
|
||||||
|
/// Show at most this many items, applied after `jq`. For a feed
|
||||||
|
/// where only the newest matters: a releases card wants the cut you
|
||||||
|
/// should run, not every cut there has ever been, and a page that
|
||||||
|
/// grows by one card per release gets longer forever.
|
||||||
|
///
|
||||||
|
/// After `jq` rather than before, so it caps what the page will
|
||||||
|
/// actually render whatever the filter did — and so content can
|
||||||
|
/// reorder with jq and still take the top of its own ordering. A
|
||||||
|
/// source that yields a single record has no length and passes
|
||||||
|
/// through untouched. `None` shows everything.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
|
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
|
||||||
@@ -527,18 +560,27 @@ pub struct ResourceSpec {
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ResourceSource {
|
pub enum ResourceSource {
|
||||||
Kv { bucket: String },
|
Kv {
|
||||||
GiteaStarred { username: String },
|
bucket: String,
|
||||||
|
},
|
||||||
|
GiteaStarred {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
|
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
|
||||||
/// for private repos too (shape the link with jq: a private
|
/// for private repos too (shape the link with jq: a private
|
||||||
/// release's html_url 404s for anonymous visitors, so map it to
|
/// release's html_url 404s for anonymous visitors, so map it to
|
||||||
/// null unless the repo is public).
|
/// null unless the repo is public).
|
||||||
GiteaReleases { owner: String, repo: String },
|
GiteaReleases {
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
},
|
||||||
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked
|
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked
|
||||||
/// against loopback/private/link-local addresses at fetch time
|
/// against loopback/private/link-local addresses at fetch time
|
||||||
/// (`resource::fetch_url_resource`) - a server-side fetch of a
|
/// (`resource::fetch_url_resource`) - a server-side fetch of a
|
||||||
/// content-supplied URL is SSRF surface, so it fails closed.
|
/// content-supplied URL is SSRF surface, so it fails closed.
|
||||||
Url { url: String },
|
Url {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResourceSpec {
|
impl ResourceSpec {
|
||||||
@@ -814,6 +856,28 @@ pub struct SiteConfig {
|
|||||||
/// skipped, never half-sent (see `mail.rs`).
|
/// skipped, never half-sent (see `mail.rs`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mail: Option<MailConfig>,
|
pub mail: Option<MailConfig>,
|
||||||
|
/// Where `/profile.mobileconfig` gets its values. Absent means the
|
||||||
|
/// site offers no configuration profile and that route is a 404.
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<ProfileConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An Apple configuration profile rendered from the mail server's own PACC
|
||||||
|
/// document - see `profile.rs`. Nothing here describes a server: `pacc` says
|
||||||
|
/// where the description already lives, so hosts and ports are not written
|
||||||
|
/// down a second time to drift from the first.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ProfileConfig {
|
||||||
|
/// The PACC document, normally
|
||||||
|
/// `https://<mail host>/.well-known/user-agent-configuration.json`.
|
||||||
|
pub pacc: String,
|
||||||
|
/// What the accounts are called on the device. Defaults to the site's
|
||||||
|
/// own host.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Shown as the profile's publisher. Defaults to `name`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub organization: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sender identity, and the one built-in mail's wording.
|
/// The sender identity, and the one built-in mail's wording.
|
||||||
@@ -855,7 +919,10 @@ pub struct HeroConfig {
|
|||||||
|
|
||||||
impl Default for HeroConfig {
|
impl Default for HeroConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { kind: default_hero_kind(), module: None }
|
Self {
|
||||||
|
kind: default_hero_kind(),
|
||||||
|
module: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,6 +962,14 @@ impl SiteConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(profile) = &self.profile {
|
||||||
|
if !profile.pacc.starts_with("https://") {
|
||||||
|
anyhow::bail!(
|
||||||
|
"site.yaml: profile.pacc {:?} must be an https:// URL (the PACC document the profile is built from)",
|
||||||
|
profile.pacc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(icon) = &self.favicon {
|
if let Some(icon) = &self.favicon {
|
||||||
let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e));
|
let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e));
|
||||||
if !is_safe_site_path(icon) || !ok {
|
if !is_safe_site_path(icon) || !ok {
|
||||||
@@ -1072,7 +1147,12 @@ pub fn earned_followups(
|
|||||||
queue.push_back(q);
|
queue.push_back(q);
|
||||||
}
|
}
|
||||||
while let Some(q) = queue.pop_front() {
|
while let Some(q) = queue.pop_front() {
|
||||||
for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) {
|
for action in q
|
||||||
|
.alternatives
|
||||||
|
.iter()
|
||||||
|
.filter(|a| asks_nothing(a))
|
||||||
|
.filter_map(|a| a.action.as_deref())
|
||||||
|
{
|
||||||
let Some(id) = action_target_id(questions, action) else {
|
let Some(id) = action_target_id(questions, action) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -1165,9 +1245,13 @@ pub fn build_questions(
|
|||||||
|
|
||||||
/// Every storyline of the content, each a full map of questions.
|
/// Every storyline of the content, each a full map of questions.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
|
pub fn build_storylines(
|
||||||
|
files: &[(String, String)],
|
||||||
|
) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
|
||||||
let arity = variants::arity(files)?;
|
let arity = variants::arity(files)?;
|
||||||
let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::<anyhow::Result<Vec<_>>>()?;
|
let each = (0..arity)
|
||||||
|
.map(|n| build_one_storyline(&variants::storyline(files, n)))
|
||||||
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
Ok(variants::Storylines { each })
|
Ok(variants::Storylines { each })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,8 +1267,8 @@ fn build_one_storyline(
|
|||||||
if name != "_section.yaml" {
|
if name != "_section.yaml" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut section: SectionConfig = serde_yaml::from_str(raw)
|
let mut section: SectionConfig =
|
||||||
.map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
|
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
|
||||||
let dir = dir_from_path(rel);
|
let dir = dir_from_path(rel);
|
||||||
if let Some(rc) = §ion.requires_chain {
|
if let Some(rc) = §ion.requires_chain {
|
||||||
section.requires_chain = Some(resolve_ref(&dir, rc));
|
section.requires_chain = Some(resolve_ref(&dir, rc));
|
||||||
@@ -1289,7 +1373,9 @@ aggregates:
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
|
fn question_with_transitions(
|
||||||
|
transitions_yaml: &str,
|
||||||
|
) -> std::collections::HashMap<String, Question> {
|
||||||
let question: Question = serde_yaml::from_str(&format!(
|
let question: Question = serde_yaml::from_str(&format!(
|
||||||
r#"
|
r#"
|
||||||
id: /t
|
id: /t
|
||||||
@@ -1309,7 +1395,6 @@ alternatives:
|
|||||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
|
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||||
let question: Question = serde_yaml::from_str(&format!(
|
let question: Question = serde_yaml::from_str(&format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -1323,6 +1408,7 @@ alternatives:
|
|||||||
public: true
|
public: true
|
||||||
source: {{ kind: kv, bucket: things }}
|
source: {{ kind: kv, bucket: things }}
|
||||||
- name: Object
|
- name: Object
|
||||||
|
action: /object
|
||||||
record_as: things
|
record_as: things
|
||||||
features:
|
features:
|
||||||
- name: Yours
|
- name: Yours
|
||||||
@@ -1343,9 +1429,13 @@ alternatives:
|
|||||||
let good = question_with_record(" - { name: case, type: record, of: things }");
|
let good = question_with_record(" - { name: case, type: record, of: things }");
|
||||||
validate_questions(&good, &schema_fixture()).unwrap();
|
validate_questions(&good, &schema_fixture()).unwrap();
|
||||||
|
|
||||||
let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }");
|
let nowhere =
|
||||||
|
question_with_record(" - { name: case, type: record, of: nothing_declared }");
|
||||||
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
|
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("has no aggregate declared"), "{err}");
|
assert!(
|
||||||
|
err.to_string().contains("has no aggregate declared"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
|
||||||
let unsaid = question_with_record(" - { name: case, type: record }");
|
let unsaid = question_with_record(" - { name: case, type: record }");
|
||||||
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
|
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
|
||||||
@@ -1354,12 +1444,19 @@ alternatives:
|
|||||||
// `of` means one thing, so it belongs to one type.
|
// `of` means one thing, so it belongs to one type.
|
||||||
let stray = question_with_record(" - { name: case, of: things }");
|
let stray = question_with_record(" - { name: case, of: things }");
|
||||||
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
|
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("only a type: record field has"), "{err}");
|
assert!(
|
||||||
|
err.to_string().contains("only a type: record field has"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
|
||||||
// The id arrives on the link; it is not looked up.
|
// 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 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();
|
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("the link it came in on carries the id"), "{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
|
// A state that grants a group makes an account for someone, so the
|
||||||
@@ -1371,7 +1468,9 @@ alternatives:
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let asks = question_with_record(" - { name: name }\n - { name: email, type: email }");
|
let asks = question_with_record(
|
||||||
|
" - { name: name }\n - { name: email, type: email }",
|
||||||
|
);
|
||||||
validate_questions(&asks, &granting).unwrap();
|
validate_questions(&asks, &granting).unwrap();
|
||||||
|
|
||||||
let no_name = question_with_record(" - { name: email, type: email }");
|
let no_name = question_with_record(" - { name: email, type: email }");
|
||||||
@@ -1394,7 +1493,10 @@ alternatives:
|
|||||||
let undeclared = crate::aggregates::parse_aggregates_yaml(
|
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",
|
"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"));
|
assert!(undeclared
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("not a declared transition"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1420,6 +1522,7 @@ id: /t
|
|||||||
name: Where is it?
|
name: Where is it?
|
||||||
alternatives:
|
alternatives:
|
||||||
- name: A
|
- name: A
|
||||||
|
action: /t
|
||||||
features:
|
features:
|
||||||
- name: Where
|
- name: Where
|
||||||
requirements:
|
requirements:
|
||||||
@@ -1444,7 +1547,10 @@ alternatives:
|
|||||||
" bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }",
|
" 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();
|
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("the visitor puts the pin"), "{err}");
|
assert!(
|
||||||
|
err.to_string().contains("the visitor puts the pin"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1454,7 +1560,13 @@ alternatives:
|
|||||||
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
|
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
|
||||||
validate_questions(&no_zoom, &schema_fixture()).unwrap();
|
validate_questions(&no_zoom, &schema_fixture()).unwrap();
|
||||||
|
|
||||||
for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] {
|
for bad in [
|
||||||
|
"\"59.8938\"",
|
||||||
|
"\"91,11\"",
|
||||||
|
"\"59,200\"",
|
||||||
|
"\"here\"",
|
||||||
|
"\"59,11,15,3\"",
|
||||||
|
] {
|
||||||
let questions = question_with_location(&format!(" value: {bad}"));
|
let questions = question_with_location(&format!(" value: {bad}"));
|
||||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
|
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
|
||||||
@@ -1463,7 +1575,12 @@ alternatives:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_page_about_a_place_says_where_and_how_close() {
|
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()) };
|
let place = Place {
|
||||||
|
lat: 59.8938,
|
||||||
|
lon: 11.0332,
|
||||||
|
zoom: 15.0,
|
||||||
|
name: Some("Tomter".into()),
|
||||||
|
};
|
||||||
place.validate().unwrap();
|
place.validate().unwrap();
|
||||||
assert_eq!(place.as_value(), "59.8938,11.0332,15");
|
assert_eq!(place.as_value(), "59.8938,11.0332,15");
|
||||||
// The default is a street and its neighbours, not the globe.
|
// The default is a street and its neighbours, not the globe.
|
||||||
@@ -1472,10 +1589,30 @@ alternatives:
|
|||||||
assert!(bare.name.is_none());
|
assert!(bare.name.is_none());
|
||||||
|
|
||||||
for bad in [
|
for bad in [
|
||||||
Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None },
|
Place {
|
||||||
Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None },
|
lat: 91.0,
|
||||||
Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None },
|
lon: 11.0,
|
||||||
Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) },
|
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:?}");
|
assert!(bad.validate().is_err(), "{bad:?}");
|
||||||
}
|
}
|
||||||
@@ -1483,7 +1620,8 @@ alternatives:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undeclared_from_state_is_rejected() {
|
fn undeclared_from_state_is_rejected() {
|
||||||
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
let questions =
|
||||||
|
question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
||||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("transition.from"));
|
assert!(err.to_string().contains("transition.from"));
|
||||||
}
|
}
|
||||||
@@ -1492,19 +1630,23 @@ alternatives:
|
|||||||
fn undeclared_edge_is_rejected() {
|
fn undeclared_edge_is_rejected() {
|
||||||
// Both states exist, but open -> done skips a step the graph
|
// Both states exist, but open -> done skips a step the graph
|
||||||
// never declared.
|
// never declared.
|
||||||
let questions = question_with_transitions(" - { from: open, to: done, label: Skip }");
|
let questions =
|
||||||
|
question_with_transitions(" - { from: open, to: done, label: Skip }");
|
||||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||||
assert!(err.to_string().contains("not a declared edge"));
|
assert!(err.to_string().contains("not a declared edge"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dangling_action_is_rejected() {
|
fn dangling_action_is_rejected() {
|
||||||
let question: Question =
|
let question: Question = serde_yaml::from_str(
|
||||||
serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n")
|
"id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n",
|
||||||
.unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
|
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
|
||||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||||
assert!(err.to_string().contains("does not match any declared question id"));
|
assert!(err
|
||||||
|
.to_string()
|
||||||
|
.contains("does not match any declared question id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1523,6 +1665,7 @@ id: /b
|
|||||||
name: B
|
name: B
|
||||||
alternatives:
|
alternatives:
|
||||||
- name: A
|
- name: A
|
||||||
|
action: /b
|
||||||
features:
|
features:
|
||||||
- name: ""
|
- name: ""
|
||||||
requirements:
|
requirements:
|
||||||
@@ -1564,7 +1707,7 @@ alternatives:
|
|||||||
|
|
||||||
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
|
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||||
let question: Question = serde_yaml::from_str(&format!(
|
let question: Question = serde_yaml::from_str(&format!(
|
||||||
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||||
@@ -1591,7 +1734,9 @@ alternatives:
|
|||||||
" - { name: email, type: email, relay: \"wss://relay.redoal.com\" }",
|
" - { name: email, type: email, relay: \"wss://relay.redoal.com\" }",
|
||||||
);
|
);
|
||||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||||
assert!(err.to_string().contains("relay only makes sense on type: gesture"));
|
assert!(err
|
||||||
|
.to_string()
|
||||||
|
.contains("relay only makes sense on type: gesture"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1630,7 +1775,8 @@ alternatives:
|
|||||||
let absolute: SiteConfig =
|
let absolute: SiteConfig =
|
||||||
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
|
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
|
||||||
assert!(absolute.validate().is_err());
|
assert!(absolute.validate().is_err());
|
||||||
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
|
let ok: SiteConfig =
|
||||||
|
serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
|
||||||
assert!(ok.validate().is_ok());
|
assert!(ok.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1650,7 +1796,10 @@ alternatives:
|
|||||||
assert_eq!(route_from_path("index.yaml"), "/");
|
assert_eq!(route_from_path("index.yaml"), "/");
|
||||||
assert_eq!(route_from_path("applied.yaml"), "/applied");
|
assert_eq!(route_from_path("applied.yaml"), "/applied");
|
||||||
assert_eq!(route_from_path("develop/index.yaml"), "/develop");
|
assert_eq!(route_from_path("develop/index.yaml"), "/develop");
|
||||||
assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal");
|
assert_eq!(
|
||||||
|
route_from_path("develop/proposal.yaml"),
|
||||||
|
"/develop/proposal"
|
||||||
|
);
|
||||||
assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]");
|
assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1665,8 +1814,10 @@ alternatives:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dynamic_patterns_capture_segments() {
|
fn dynamic_patterns_capture_segments() {
|
||||||
assert_eq!(path_matches("/review/[record]", "/review/abc"),
|
assert_eq!(
|
||||||
Some(vec![("record".into(), "abc".into())]));
|
path_matches("/review/[record]", "/review/abc"),
|
||||||
|
Some(vec![("record".into(), "abc".into())])
|
||||||
|
);
|
||||||
assert_eq!(path_matches("/review/[record]", "/review"), None);
|
assert_eq!(path_matches("/review/[record]", "/review"), None);
|
||||||
assert_eq!(path_matches("/review/[record]", "/other/abc"), None);
|
assert_eq!(path_matches("/review/[record]", "/other/abc"), None);
|
||||||
assert_eq!(path_matches("/review", "/review"), Some(vec![]));
|
assert_eq!(path_matches("/review", "/review"), Some(vec![]));
|
||||||
@@ -1675,25 +1826,55 @@ alternatives:
|
|||||||
#[test]
|
#[test]
|
||||||
fn tree_becomes_router_with_inference_and_sections() {
|
fn tree_becomes_router_with_inference_and_sections() {
|
||||||
let files = vec![
|
let files = vec![
|
||||||
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()),
|
(
|
||||||
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
"index.yaml".to_string(),
|
||||||
("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()),
|
"name: Home\nalternatives:\n - name: go\n action: applied\n".to_string(),
|
||||||
("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()),
|
),
|
||||||
("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()),
|
(
|
||||||
("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()),
|
"applied.yaml".to_string(),
|
||||||
("review/index.yaml".to_string(), "name: Review\n".to_string()),
|
"name: Applied\nfollowup: true\n".to_string(),
|
||||||
("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()),
|
),
|
||||||
|
(
|
||||||
|
"develop/index.yaml".to_string(),
|
||||||
|
"name: Develop\nalternatives:\n - name: propose\n action: proposal\n"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"develop/proposal.yaml".to_string(),
|
||||||
|
"name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"develop/proposed.yaml".to_string(),
|
||||||
|
"name: Proposed\n".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"review/_section.yaml".to_string(),
|
||||||
|
"qualifies: portal_owners\n".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"review/index.yaml".to_string(),
|
||||||
|
"name: Review\n".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"review/_draft.yaml".to_string(),
|
||||||
|
"not even valid yaml: [\n".to_string(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
let questions = build_questions(&files).unwrap();
|
let questions = build_questions(&files).unwrap();
|
||||||
|
|
||||||
// Derived ids and resolved relative actions.
|
// Derived ids and resolved relative actions.
|
||||||
assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied"));
|
assert_eq!(
|
||||||
|
questions["/"].alternatives[0].action.as_deref(),
|
||||||
|
Some("/applied")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
questions["/develop"].alternatives[0].action.as_deref(),
|
questions["/develop"].alternatives[0].action.as_deref(),
|
||||||
Some("/develop/proposal")
|
Some("/develop/proposal")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
questions["/develop/proposal"].alternatives[0].action.as_deref(),
|
questions["/develop/proposal"].alternatives[0]
|
||||||
|
.action
|
||||||
|
.as_deref(),
|
||||||
Some("/develop/proposed")
|
Some("/develop/proposed")
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1705,7 +1886,10 @@ alternatives:
|
|||||||
assert!(questions["/develop/proposal"].is_followup());
|
assert!(questions["/develop/proposal"].is_followup());
|
||||||
|
|
||||||
// Section inheritance gates the directory; drafts are skipped.
|
// Section inheritance gates the directory; drafts are skipped.
|
||||||
assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners"));
|
assert_eq!(
|
||||||
|
questions["/review"].qualifies.as_deref(),
|
||||||
|
Some("portal_owners")
|
||||||
|
);
|
||||||
assert_eq!(questions["/"].qualifies, None);
|
assert_eq!(questions["/"].qualifies, None);
|
||||||
assert!(!questions.contains_key("/review/_draft"));
|
assert!(!questions.contains_key("/review/_draft"));
|
||||||
}
|
}
|
||||||
@@ -1736,7 +1920,10 @@ alternatives:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Templated actions resolve to the dynamic page's pattern.
|
// Templated actions resolve to the dynamic page's pattern.
|
||||||
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
|
assert_eq!(
|
||||||
|
action_target_id(&questions, "/shape/{curve.key}").as_deref(),
|
||||||
|
Some("/shape/[key]")
|
||||||
|
);
|
||||||
assert_eq!(action_target_id(&questions, "/nowhere"), None);
|
assert_eq!(action_target_id(&questions, "/nowhere"), None);
|
||||||
|
|
||||||
// No chain, no followups: standing on the front door earns none
|
// No chain, no followups: standing on the front door earns none
|
||||||
@@ -1747,9 +1934,18 @@ alternatives:
|
|||||||
assert_eq!(earned("/applied", &[]), set(&[]));
|
assert_eq!(earned("/applied", &[]), set(&[]));
|
||||||
|
|
||||||
// A verified answer earns exactly where it sent the visitor.
|
// A verified answer earns exactly where it sent the visitor.
|
||||||
assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]);
|
assert_eq!(
|
||||||
assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]);
|
unlocked_by(&questions, "/", Some("apply")),
|
||||||
assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]);
|
vec!["/applied"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unlocked_by(&questions, "/", Some("draw")),
|
||||||
|
vec!["/shape/[key]"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unlocked_by(&questions, "/kontakt", Some("send")),
|
||||||
|
vec!["/takk"]
|
||||||
|
);
|
||||||
assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"]));
|
assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"]));
|
||||||
// An old node without the alternative: the answered question's
|
// An old node without the alternative: the answered question's
|
||||||
// recording alternatives, and nothing beyond that question.
|
// recording alternatives, and nothing beyond that question.
|
||||||
@@ -1761,7 +1957,10 @@ alternatives:
|
|||||||
// "watch" records an answer, so /subscribed is not earned by
|
// "watch" records an answer, so /subscribed is not earned by
|
||||||
// having drawn a shape; "back" leads to the front door, which
|
// having drawn a shape; "back" leads to the front door, which
|
||||||
// is never a followup and ends the walk.
|
// is never a followup and ends the walk.
|
||||||
assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"]));
|
assert_eq!(
|
||||||
|
earned("/shape/abc", &["/shape/[key]"]),
|
||||||
|
set(&["/shape/[key]"])
|
||||||
|
);
|
||||||
assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"]));
|
assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"]));
|
||||||
// The email-link page is earned by nothing on the site.
|
// The email-link page is earned by nothing on the site.
|
||||||
assert!(!earned("/", &["/applied"]).contains("/watching"));
|
assert!(!earned("/", &["/applied"]).contains("/watching"));
|
||||||
@@ -1802,7 +2001,12 @@ alternatives:
|
|||||||
let page = resolve_question(&questions, "/review/abc123").unwrap();
|
let page = resolve_question(&questions, "/review/abc123").unwrap();
|
||||||
assert_eq!(page.id, "/review/abc123");
|
assert_eq!(page.id, "/review/abc123");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(),
|
page.alternatives[0].features[0]
|
||||||
|
.resource
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.key
|
||||||
|
.as_deref(),
|
||||||
Some("abc123")
|
Some("abc123")
|
||||||
);
|
);
|
||||||
assert!(resolve_question(&questions, "/review").is_none());
|
assert!(resolve_question(&questions, "/review").is_none());
|
||||||
@@ -1820,7 +2024,10 @@ alternatives:
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
let questions = build_questions(&files).unwrap();
|
let questions = build_questions(&files).unwrap();
|
||||||
assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape"));
|
assert_eq!(
|
||||||
|
questions["/shaped"].requires_chain.as_deref(),
|
||||||
|
Some("/shape")
|
||||||
|
);
|
||||||
assert!(validate_questions(&questions, &Default::default()).is_ok());
|
assert!(validate_questions(&questions, &Default::default()).is_ok());
|
||||||
|
|
||||||
let dangling = vec![(
|
let dangling = vec![(
|
||||||
@@ -1835,7 +2042,8 @@ alternatives:
|
|||||||
fn recorded_buckets_must_be_attended() {
|
fn recorded_buckets_must_be_attended() {
|
||||||
let write_only = vec![(
|
let write_only = vec![(
|
||||||
"index.yaml".to_string(),
|
"index.yaml".to_string(),
|
||||||
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
|
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n"
|
||||||
|
.to_string(),
|
||||||
)];
|
)];
|
||||||
let questions = build_questions(&write_only).unwrap();
|
let questions = build_questions(&write_only).unwrap();
|
||||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||||
@@ -1846,7 +2054,7 @@ alternatives:
|
|||||||
let with_desk = vec![
|
let with_desk = vec![
|
||||||
(
|
(
|
||||||
"index.yaml".to_string(),
|
"index.yaml".to_string(),
|
||||||
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
|
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"review/index.yaml".to_string(),
|
"review/index.yaml".to_string(),
|
||||||
@@ -1870,9 +2078,13 @@ alternatives:
|
|||||||
let files = vec![
|
let files = vec![
|
||||||
(
|
(
|
||||||
"index.yaml".to_string(),
|
"index.yaml".to_string(),
|
||||||
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(),
|
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"review/[record].yaml".to_string(),
|
||||||
|
"name: Record\n".to_string(),
|
||||||
),
|
),
|
||||||
("review/[record].yaml".to_string(), "name: Record\n".to_string()),
|
|
||||||
];
|
];
|
||||||
let questions = build_questions(&files).unwrap();
|
let questions = build_questions(&files).unwrap();
|
||||||
assert!(validate_questions(&questions, &Default::default()).is_err());
|
assert!(validate_questions(&questions, &Default::default()).is_err());
|
||||||
@@ -1915,7 +2127,9 @@ alternatives:
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let mut qs = std::collections::HashMap::new();
|
let mut qs = std::collections::HashMap::new();
|
||||||
qs.insert(bad.id.clone(), bad);
|
qs.insert(bad.id.clone(), bad);
|
||||||
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
|
let err = validate_questions(&qs, &Default::default())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
assert!(err.contains("bad starts"), "{err}");
|
assert!(err.contains("bad starts"), "{err}");
|
||||||
|
|
||||||
let dynamic: Question = serde_yaml::from_str(
|
let dynamic: Question = serde_yaml::from_str(
|
||||||
@@ -1924,7 +2138,9 @@ alternatives:
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let mut qs = std::collections::HashMap::new();
|
let mut qs = std::collections::HashMap::new();
|
||||||
qs.insert(dynamic.id.clone(), dynamic);
|
qs.insert(dynamic.id.clone(), dynamic);
|
||||||
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
|
let err = validate_questions(&qs, &Default::default())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
assert!(err.contains("dynamic page can't carry an event"), "{err}");
|
assert!(err.contains("dynamic page can't carry an event"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1933,14 +2149,20 @@ alternatives:
|
|||||||
let merged = with_builtin_aggregates(Default::default());
|
let merged = with_builtin_aggregates(Default::default());
|
||||||
let schema = &merged[EVENTS_BUCKET];
|
let schema = &merged[EVENTS_BUCKET];
|
||||||
assert_eq!(schema.initial, "announced");
|
assert_eq!(schema.initial, "announced");
|
||||||
assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]);
|
assert_eq!(
|
||||||
|
schema.allowed("announced"),
|
||||||
|
["awaiting_summary", "summarized"]
|
||||||
|
);
|
||||||
assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]);
|
assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]);
|
||||||
|
|
||||||
let content = crate::aggregates::parse_aggregates_yaml(
|
let content = crate::aggregates::parse_aggregates_yaml(
|
||||||
"aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n",
|
"aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine");
|
assert_eq!(
|
||||||
|
with_builtin_aggregates(content)[EVENTS_BUCKET].initial,
|
||||||
|
"mine"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1966,20 +2188,39 @@ alternatives:
|
|||||||
fn a_signed_in_person_is_not_asked_who_they_are() {
|
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 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 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 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();
|
let mut signed_in = q.clone();
|
||||||
fill_from_user(&mut signed_in, Some(&user));
|
fill_from_user(&mut signed_in, Some(&user));
|
||||||
let reqs = &signed_in.alternatives[0].features[0].requirements;
|
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!(
|
||||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
|
(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.
|
// Mixed text is not a user field, and is left as written.
|
||||||
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
|
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
|
||||||
// A visitor is asked: the placeholder goes, the field stays.
|
// A visitor is asked: the placeholder goes, the field stays.
|
||||||
let mut visitor = q.clone();
|
let mut visitor = q.clone();
|
||||||
fill_from_user(&mut visitor, None);
|
fill_from_user(&mut visitor, None);
|
||||||
let reqs = &visitor.alternatives[0].features[0].requirements;
|
let reqs = &visitor.alternatives[0].features[0].requirements;
|
||||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
|
assert_eq!(
|
||||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
|
(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]
|
#[test]
|
||||||
@@ -2025,8 +2266,14 @@ alternatives:
|
|||||||
render_inline_markdown(""),
|
render_inline_markdown(""),
|
||||||
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
|
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
|
||||||
);
|
);
|
||||||
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
|
assert_eq!(
|
||||||
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
|
render_inline_markdown("[ok](/shape)"),
|
||||||
|
"<a href=\"/shape\">ok</a>"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render_inline_markdown("[mail](mailto:bl@uhhm.no)"),
|
||||||
|
"<a href=\"mailto:bl@uhhm.no\">mail</a>"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2048,7 +2295,10 @@ alternatives:
|
|||||||
#[test]
|
#[test]
|
||||||
fn action_templates_from_responses() {
|
fn action_templates_from_responses() {
|
||||||
let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3});
|
let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3});
|
||||||
assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98"));
|
assert_eq!(
|
||||||
|
template_action("/shape/{curve.key}", &r).as_deref(),
|
||||||
|
Some("/shape/20aa98")
|
||||||
|
);
|
||||||
assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y"));
|
assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y"));
|
||||||
assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape"));
|
assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape"));
|
||||||
assert!(template_action("/shape/{curve.missing}", &r).is_none());
|
assert!(template_action("/shape/{curve.missing}", &r).is_none());
|
||||||
@@ -2066,7 +2316,12 @@ alternatives:
|
|||||||
let mut qs = std::collections::HashMap::new();
|
let mut qs = std::collections::HashMap::new();
|
||||||
qs.insert(q.id.clone(), q);
|
qs.insert(q.id.clone(), q);
|
||||||
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
||||||
assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98"));
|
assert_eq!(
|
||||||
|
page.alternatives[0].features[0].requirements[0]
|
||||||
|
.value
|
||||||
|
.as_deref(),
|
||||||
|
Some("20aa98")
|
||||||
|
);
|
||||||
|
|
||||||
let q: Question = serde_yaml::from_str(
|
let q: Question = serde_yaml::from_str(
|
||||||
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
|
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
|
||||||
@@ -2075,7 +2330,12 @@ alternatives:
|
|||||||
let mut qs = std::collections::HashMap::new();
|
let mut qs = std::collections::HashMap::new();
|
||||||
qs.insert(q.id.clone(), q);
|
qs.insert(q.id.clone(), q);
|
||||||
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
||||||
match &page.alternatives[0].features[0].resource.as_ref().unwrap().source {
|
match &page.alternatives[0].features[0]
|
||||||
|
.resource
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.source
|
||||||
|
{
|
||||||
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
|
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-20
@@ -107,12 +107,18 @@ pub fn with_builtin_aggregates(
|
|||||||
/// runtime's own `portal_events` (a warning, not a failure: a repo
|
/// runtime's own `portal_events` (a warning, not a failure: a repo
|
||||||
/// with no event pages has nothing to attend).
|
/// with no event pages has nothing to attend).
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
|
pub fn bucket_is_read(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
bucket: &str,
|
||||||
|
) -> bool {
|
||||||
questions.values().any(|q| {
|
questions.values().any(|q| {
|
||||||
q.alternatives.iter().any(|a| {
|
q.alternatives.iter().any(|a| {
|
||||||
a.features
|
a.features.iter().any(|f| {
|
||||||
.iter()
|
f.resource
|
||||||
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
|
.as_ref()
|
||||||
|
.and_then(|r| r.bucket())
|
||||||
|
.is_some_and(|b| b == bucket)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -125,12 +131,21 @@ pub fn validate_questions(
|
|||||||
// A mail to the submitter needs an address to go to: every form
|
// A mail to the submitter needs an address to go to: every form
|
||||||
// that records into that bucket has to collect a required email.
|
// that records into that bucket has to collect a required email.
|
||||||
for (bucket, schema) in aggregates {
|
for (bucket, schema) in aggregates {
|
||||||
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
|
let submitter_states: Vec<&String> = schema
|
||||||
|
.mail_for_state
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, m)| m.to_submitter())
|
||||||
|
.map(|(s, _)| s)
|
||||||
|
.collect();
|
||||||
if submitter_states.is_empty() {
|
if submitter_states.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for question in questions.values() {
|
for question in questions.values() {
|
||||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
for alternative in question
|
||||||
|
.alternatives
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
|
||||||
|
{
|
||||||
let collects_email = alternative
|
let collects_email = alternative
|
||||||
.features
|
.features
|
||||||
.iter()
|
.iter()
|
||||||
@@ -154,10 +169,20 @@ pub fn validate_questions(
|
|||||||
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
|
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
|
||||||
}
|
}
|
||||||
if !(20..=100).contains(&show.max_height) {
|
if !(20..=100).contains(&show.max_height) {
|
||||||
anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height);
|
anyhow::bail!(
|
||||||
|
"question {:?}: slideshow max_height is {} svh; between 20 and 100",
|
||||||
|
question.id,
|
||||||
|
show.max_height
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for slide in &show.slides {
|
for slide in &show.slides {
|
||||||
let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/'));
|
let ok = slide.image.starts_with("https://")
|
||||||
|
|| crate::content::is_safe_site_path(
|
||||||
|
slide
|
||||||
|
.image
|
||||||
|
.trim_start_matches("/site/")
|
||||||
|
.trim_start_matches('/'),
|
||||||
|
);
|
||||||
if !ok {
|
if !ok {
|
||||||
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
|
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
|
||||||
}
|
}
|
||||||
@@ -171,10 +196,20 @@ pub fn validate_questions(
|
|||||||
// has nowhere to go.
|
// has nowhere to go.
|
||||||
for (bucket, schema) in aggregates {
|
for (bucket, schema) in aggregates {
|
||||||
for (state, spec) in &schema.mail_for_state {
|
for (state, spec) in &schema.mail_for_state {
|
||||||
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
|
let Some(parent) = crate::mail::parent_of(&spec.to) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
for question in questions.values() {
|
for question in questions.values() {
|
||||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
for alternative in question
|
||||||
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
.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 {
|
if !has {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
||||||
@@ -199,7 +234,11 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
granting.sort();
|
granting.sort();
|
||||||
for question in questions.values() {
|
for question in questions.values() {
|
||||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
for alternative in question
|
||||||
|
.alternatives
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
|
||||||
|
{
|
||||||
let required = |name: &str, kind: &str| {
|
let required = |name: &str, kind: &str| {
|
||||||
alternative
|
alternative
|
||||||
.features
|
.features
|
||||||
@@ -220,7 +259,10 @@ pub fn validate_questions(
|
|||||||
for question in questions.values() {
|
for question in questions.values() {
|
||||||
if let Some(event) = &question.event {
|
if let Some(event) = &question.event {
|
||||||
if question.is_dynamic() {
|
if question.is_dynamic() {
|
||||||
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
|
anyhow::bail!(
|
||||||
|
"question {:?}: a dynamic page can't carry an event",
|
||||||
|
question.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Err(e) = event.window_ms() {
|
if let Err(e) = event.window_ms() {
|
||||||
anyhow::bail!("question {:?}: event: {e}", question.id);
|
anyhow::bail!("question {:?}: event: {e}", question.id);
|
||||||
@@ -361,10 +403,12 @@ pub fn validate_questions(
|
|||||||
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
|
validate_relay_url(relay).map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
|
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
|
||||||
question.id, alternative.name, feature.name, requirement.name
|
question.id, alternative.name, feature.name, requirement.name
|
||||||
))?;
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
if requirement.kind == "gesture"
|
if requirement.kind == "gesture"
|
||||||
&& (requirement.resource.is_some() || requirement.bind.is_some())
|
&& (requirement.resource.is_some() || requirement.bind.is_some())
|
||||||
@@ -388,8 +432,12 @@ pub fn validate_questions(
|
|||||||
let parts: Vec<&str> = value.split(',').map(str::trim).collect();
|
let parts: Vec<&str> = value.split(',').map(str::trim).collect();
|
||||||
let ok = (2..=3).contains(&parts.len())
|
let ok = (2..=3).contains(&parts.len())
|
||||||
&& parts.iter().all(|p| p.parse::<f64>().is_ok())
|
&& parts.iter().all(|p| p.parse::<f64>().is_ok())
|
||||||
&& parts[0].parse::<f64>().is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
|
&& parts[0]
|
||||||
&& parts[1].parse::<f64>().is_ok_and(|lon| (-180.0..=180.0).contains(&lon));
|
.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 {
|
if !ok {
|
||||||
anyhow::bail!(
|
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 {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}",
|
||||||
@@ -468,7 +516,11 @@ pub fn validate_questions(
|
|||||||
question.id, alternative.name, feature.name, transition.to, bucket
|
question.id, alternative.name, feature.name, transition.to, bucket
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
if !schema
|
||||||
|
.allowed(&transition.from)
|
||||||
|
.iter()
|
||||||
|
.any(|s| s == &transition.to)
|
||||||
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||||
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||||||
@@ -515,7 +567,10 @@ pub fn validate_questions(
|
|||||||
// A form is sent with its button, and the button is only
|
// A form is sent with its button, and the button is only
|
||||||
// drawn for an alternative that says where to go afterwards:
|
// drawn for an alternative that says where to go afterwards:
|
||||||
// without `action` the person fills it in and cannot send it.
|
// without `action` the person fills it in and cannot send it.
|
||||||
let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
|
let asks = alternative
|
||||||
|
.features
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
|
||||||
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
|
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
|
||||||
&& alternative.action.is_none()
|
&& alternative.action.is_none()
|
||||||
&& alternative.self_transition.is_none()
|
&& alternative.self_transition.is_none()
|
||||||
@@ -549,4 +604,3 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-17
@@ -31,7 +31,16 @@
|
|||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
|
|
||||||
/// Keys whose value is one string to a person.
|
/// Keys whose value is one string to a person.
|
||||||
const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"];
|
const TEXT: &[&str] = &[
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"label",
|
||||||
|
"placeholder",
|
||||||
|
"subject",
|
||||||
|
"body",
|
||||||
|
"empty",
|
||||||
|
"title",
|
||||||
|
];
|
||||||
/// Keys whose value is a list of strings to a person; their storylines
|
/// Keys whose value is a list of strings to a person; their storylines
|
||||||
/// are a list of lists.
|
/// are a list of lists.
|
||||||
const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
|
const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
|
||||||
@@ -41,7 +50,12 @@ const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
|
|||||||
fn is_identifier(path: &[String], key: &str) -> bool {
|
fn is_identifier(path: &[String], key: &str) -> bool {
|
||||||
// `path` ends with the key itself, then the item's index, then the
|
// `path` ends with the key itself, then the item's index, then the
|
||||||
// list it is in.
|
// list it is in.
|
||||||
key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements")
|
key == "name"
|
||||||
|
&& path
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.nth(2)
|
||||||
|
.is_some_and(|p| p == "requirements")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_strings(seq: &[Value]) -> bool {
|
fn is_strings(seq: &[Value]) -> bool {
|
||||||
@@ -49,7 +63,11 @@ fn is_strings(seq: &[Value]) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_lists_of_strings(seq: &[Value]) -> bool {
|
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())))
|
!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.
|
/// The storyline counts found in a document, with where each was.
|
||||||
@@ -60,8 +78,18 @@ fn counts(value: &Value, path: &mut Vec<String>, out: &mut Vec<(String, usize)>)
|
|||||||
let key = k.as_str().unwrap_or_default().to_string();
|
let key = k.as_str().unwrap_or_default().to_string();
|
||||||
path.push(key.clone());
|
path.push(key.clone());
|
||||||
let variants = match v {
|
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)
|
||||||
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq.len()),
|
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,
|
_ => None,
|
||||||
};
|
};
|
||||||
match variants {
|
match variants {
|
||||||
@@ -90,8 +118,18 @@ fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
|
|||||||
let key = k.as_str().unwrap_or_default().to_string();
|
let key = k.as_str().unwrap_or_default().to_string();
|
||||||
path.push(key.clone());
|
path.push(key.clone());
|
||||||
let chosen = match v {
|
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)
|
||||||
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
|
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,
|
_ => None,
|
||||||
};
|
};
|
||||||
match chosen {
|
match chosen {
|
||||||
@@ -118,7 +156,9 @@ fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
|
|||||||
pub fn arity(files: &[(String, String)]) -> anyhow::Result<usize> {
|
pub fn arity(files: &[(String, String)]) -> anyhow::Result<usize> {
|
||||||
let mut found: Vec<(String, String, usize)> = Vec::new();
|
let mut found: Vec<(String, String, usize)> = Vec::new();
|
||||||
for (rel, raw) in files {
|
for (rel, raw) in files {
|
||||||
let Ok(value) = serde_yaml::from_str::<Value>(raw) else { continue };
|
let Ok(value) = serde_yaml::from_str::<Value>(raw) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
counts(&value, &mut Vec::new(), &mut out);
|
counts(&value, &mut Vec::new(), &mut out);
|
||||||
found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n)));
|
found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n)));
|
||||||
@@ -135,14 +175,19 @@ pub fn storyline(files: &[(String, String)], n: usize) -> Vec<(String, String)>
|
|||||||
files
|
files
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(rel, raw)| {
|
.map(|(rel, raw)| {
|
||||||
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else { return (rel.clone(), raw.clone()) };
|
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else {
|
||||||
|
return (rel.clone(), raw.clone());
|
||||||
|
};
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
counts(&value, &mut Vec::new(), &mut out);
|
counts(&value, &mut Vec::new(), &mut out);
|
||||||
if out.is_empty() {
|
if out.is_empty() {
|
||||||
return (rel.clone(), raw.clone());
|
return (rel.clone(), raw.clone());
|
||||||
}
|
}
|
||||||
pick(&mut value, &mut Vec::new(), n);
|
pick(&mut value, &mut Vec::new(), n);
|
||||||
(rel.clone(), serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()))
|
(
|
||||||
|
rel.clone(),
|
||||||
|
serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -192,17 +237,32 @@ mod tests {
|
|||||||
assert_eq!(v["name"], "Trenger naboene deg?");
|
assert_eq!(v["name"], "Trenger naboene deg?");
|
||||||
assert_eq!(v["description"], "Alle trenger flere hender.");
|
assert_eq!(v["description"], "Alle trenger flere hender.");
|
||||||
assert_eq!(v["alternatives"][0]["name"], "Meld deg");
|
assert_eq!(v["alternatives"][0]["name"], "Meld deg");
|
||||||
assert_eq!(v["alternatives"][0]["consequence"], serde_yaml::to_value(vec!["Meld meg"]).unwrap());
|
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.
|
// 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!(
|
||||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["label"], "Hva heter du?");
|
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.
|
// 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());
|
assert_eq!(
|
||||||
|
v["alternatives"][0]["features"][0]["requirements"][1]["options"],
|
||||||
|
serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused() {
|
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())];
|
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!(arity(&plain).unwrap(), 1);
|
||||||
assert_eq!(storyline(&plain, 0)[0].1, plain[0].1);
|
assert_eq!(storyline(&plain, 0)[0].1, plain[0].1);
|
||||||
let uneven = vec![
|
let uneven = vec![
|
||||||
@@ -210,12 +270,18 @@ mod tests {
|
|||||||
("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()),
|
("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()),
|
||||||
];
|
];
|
||||||
let err = arity(&uneven).unwrap_err().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}");
|
assert!(
|
||||||
|
err.contains("b.yaml: name tells 3") && err.contains("tells 2")
|
||||||
|
|| err.contains("tells 3"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_first_storyline_is_what_the_machinery_reads() {
|
fn the_first_storyline_is_what_the_machinery_reads() {
|
||||||
let s = Storylines { each: vec!["a", "b"] };
|
let s = Storylines {
|
||||||
|
each: vec!["a", "b"],
|
||||||
|
};
|
||||||
assert_eq!(*s, "a");
|
assert_eq!(*s, "a");
|
||||||
assert_eq!(*s.at(1), "b");
|
assert_eq!(*s.at(1), "b");
|
||||||
assert_eq!(*s.at(7), "b");
|
assert_eq!(*s.at(7), "b");
|
||||||
|
|||||||
+86
-23
@@ -63,8 +63,13 @@ pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, no
|
|||||||
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
|
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let aggregates = state.aggregates.load();
|
let aggregates = state.aggregates.load();
|
||||||
for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) {
|
for schema in aggregates
|
||||||
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { continue };
|
.values()
|
||||||
|
.filter(|s| !s.leaves_for_state.is_empty())
|
||||||
|
{
|
||||||
|
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let keys: Vec<String> = {
|
let keys: Vec<String> = {
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
match store.keys().await {
|
match store.keys().await {
|
||||||
@@ -73,12 +78,20 @@ pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for id in keys {
|
for id in keys {
|
||||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
let Ok(Some(bytes)) = store.get(&id).await else {
|
||||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if has_left(schema, &answer, now_ms) {
|
if has_left(schema, &answer, now_ms) {
|
||||||
match store.delete(&id).await {
|
match store.delete(&id).await {
|
||||||
Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"),
|
Ok(()) => {
|
||||||
Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"),
|
tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list")
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +106,11 @@ fn waiting_since(answer: &Answer) -> i64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a record in `state` has waited out its deadline by `now_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> {
|
pub fn due(
|
||||||
|
schema: &crate::aggregates::AggregateSchema,
|
||||||
|
answer: &Answer,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Option<String> {
|
||||||
let after = schema.after_for(&answer.state)?;
|
let after = schema.after_for(&answer.state)?;
|
||||||
let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS;
|
let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS;
|
||||||
(now_ms >= deadline).then(|| after.to.clone())
|
(now_ms >= deadline).then(|| after.to.clone())
|
||||||
@@ -120,13 +137,24 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for id in keys {
|
for id in keys {
|
||||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
let Ok(Some(bytes)) = store.get(&id).await else {
|
||||||
let Ok(mut answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
continue;
|
||||||
let Some(target) = due(schema, &answer, now_ms) 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 });
|
let payload = serde_json::json!({ "to": target, "item": id, "by": BY });
|
||||||
if let Err(e) = crate::aggregates::transition(
|
if let Err(e) = crate::aggregates::transition(
|
||||||
&state.jetstream, schema, &id, &target, payload.clone(), now_ms,
|
&state.jetstream,
|
||||||
|
schema,
|
||||||
|
&id,
|
||||||
|
&target,
|
||||||
|
payload.clone(),
|
||||||
|
now_ms,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -143,11 +171,20 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||||
let _ = store.put(&id, data.into()).await;
|
let _ = store.put(&id, data.into()).await;
|
||||||
}
|
}
|
||||||
crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses, answer.storyline).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;
|
crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
|
||||||
|
|
||||||
let parent_hashes = vec![id.clone()];
|
let parent_hashes = vec![id.clone()];
|
||||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
let chain_hash =
|
||||||
|
crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||||
if let Err(e) = emit_answer_submitted(
|
if let Err(e) = emit_answer_submitted(
|
||||||
&state.nats,
|
&state.nats,
|
||||||
&AnswerSubmitted {
|
&AnswerSubmitted {
|
||||||
@@ -175,7 +212,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn schema(yaml: &str) -> crate::aggregates::AggregateSchema {
|
fn schema(yaml: &str) -> crate::aggregates::AggregateSchema {
|
||||||
crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap()
|
crate::aggregates::parse_aggregates_yaml(yaml)
|
||||||
|
.unwrap()
|
||||||
|
.into_values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEARINGS: &str = r#"
|
const HEARINGS: &str = r#"
|
||||||
@@ -192,9 +233,16 @@ aggregates:
|
|||||||
|
|
||||||
fn waiting(state: &str, since_ms: i64) -> Answer {
|
fn waiting(state: &str, since_ms: i64) -> Answer {
|
||||||
Answer {
|
Answer {
|
||||||
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
id: "r".into(),
|
||||||
responses: serde_json::json!({}), state: state.into(),
|
question_id: "/q".into(),
|
||||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
|
alternative: "a".into(),
|
||||||
|
responses: serde_json::json!({}),
|
||||||
|
state: state.into(),
|
||||||
|
submitted_ms: since_ms,
|
||||||
|
decided_ms: None,
|
||||||
|
decided_by: None,
|
||||||
|
notes: vec![],
|
||||||
|
finished: false,
|
||||||
storyline: 0,
|
storyline: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +255,10 @@ aggregates:
|
|||||||
let t = 1_000_000_000_000;
|
let t = 1_000_000_000_000;
|
||||||
let mut answer = waiting("gone", t - 60 * 60 * 1000);
|
let mut answer = waiting("gone", t - 60 * 60 * 1000);
|
||||||
answer.decided_ms = Some(t);
|
answer.decided_ms = Some(t);
|
||||||
assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending");
|
assert!(
|
||||||
|
!has_left(&s, &answer, t + 9 * 60 * 1000),
|
||||||
|
"measured from entering the state, not from sending"
|
||||||
|
);
|
||||||
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
|
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
|
||||||
// A state without it keeps its records.
|
// A state without it keeps its records.
|
||||||
assert!(!has_left(&s, &waiting("open", 0), t));
|
assert!(!has_left(&s, &waiting("open", 0), t));
|
||||||
@@ -221,7 +272,10 @@ aggregates:
|
|||||||
let answer = waiting("open", sent);
|
let answer = waiting("open", sent);
|
||||||
assert_eq!(due(&s, &answer, sent), None);
|
assert_eq!(due(&s, &answer, sent), None);
|
||||||
assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None);
|
assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None);
|
||||||
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), Some("no_answer".into()));
|
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
|
// The six weeks run from the last move, not from the day the case
|
||||||
@@ -233,7 +287,10 @@ aggregates:
|
|||||||
let mut answer = waiting("open", sent);
|
let mut answer = waiting("open", sent);
|
||||||
answer.decided_ms = Some(sent + 40 * DAY_MS);
|
answer.decided_ms = Some(sent + 40 * DAY_MS);
|
||||||
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None);
|
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None);
|
||||||
assert_eq!(due(&s, &answer, sent + 82 * DAY_MS), Some("no_answer".into()));
|
assert_eq!(
|
||||||
|
due(&s, &answer, sent + 82 * DAY_MS),
|
||||||
|
Some("no_answer".into())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -248,15 +305,21 @@ aggregates:
|
|||||||
// not when the hour comes round.
|
// not when the hour comes round.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_deadline_onto_an_undeclared_edge_is_refused_on_load() {
|
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 bad = HEARINGS
|
||||||
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
|
.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}");
|
assert!(err.contains("not a declared transition"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_deadline_of_no_days_is_refused() {
|
fn a_deadline_of_no_days_is_refused() {
|
||||||
let bad = HEARINGS.replace("days: 42", "days: 0");
|
let bad = HEARINGS.replace("days: 42", "days: 0");
|
||||||
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
|
let err = crate::aggregates::parse_aggregates_yaml(&bad)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
assert!(err.contains("at least one"), "{err}");
|
assert!(err.contains("at least one"), "{err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -109,11 +109,16 @@ pub async fn load_events(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("fetching messages: {e}"))?
|
.map_err(|e| anyhow::anyhow!("fetching messages: {e}"))?
|
||||||
{
|
{
|
||||||
let info = message.info().map_err(|e| anyhow::anyhow!("reading message info: {e}"))?;
|
let info = message
|
||||||
|
.info()
|
||||||
|
.map_err(|e| anyhow::anyhow!("reading message info: {e}"))?;
|
||||||
let mut event: Event = serde_json::from_slice(&message.payload)?;
|
let mut event: Event = serde_json::from_slice(&message.payload)?;
|
||||||
event.sequence = info.stream_sequence;
|
event.sequence = info.stream_sequence;
|
||||||
events.push(event);
|
events.push(event);
|
||||||
message.ack().await.map_err(|e| anyhow::anyhow!("acking: {e}"))?;
|
message
|
||||||
|
.ack()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("acking: {e}"))?;
|
||||||
}
|
}
|
||||||
events.sort_by_key(|e| e.sequence);
|
events.sort_by_key(|e| e.sequence);
|
||||||
|
|
||||||
|
|||||||
+12
@@ -24,6 +24,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("en", "sign_in") => "Sign in",
|
("en", "sign_in") => "Sign in",
|
||||||
("en", "sign_out") => "Sign out",
|
("en", "sign_out") => "Sign out",
|
||||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||||
|
("en", "show_older") => "Show older",
|
||||||
|
("en", "for_this_device") => "For this device",
|
||||||
|
("en", "all_downloads") => "All downloads",
|
||||||
|
("en", "release_notes") => "release notes",
|
||||||
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
||||||
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
||||||
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
||||||
@@ -60,6 +64,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("no", "sign_in") => "Logg inn",
|
("no", "sign_in") => "Logg inn",
|
||||||
("no", "sign_out") => "Logg ut",
|
("no", "sign_out") => "Logg ut",
|
||||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||||
|
("no", "show_older") => "Vis eldre",
|
||||||
|
("no", "for_this_device") => "Til denne enheten",
|
||||||
|
("no", "all_downloads") => "Alle nedlastinger",
|
||||||
|
("no", "release_notes") => "utgivelsesnotater",
|
||||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
||||||
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
||||||
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
||||||
@@ -114,6 +122,10 @@ mod tests {
|
|||||||
"sign_in",
|
"sign_in",
|
||||||
"sign_out",
|
"sign_out",
|
||||||
"nothing_here_yet",
|
"nothing_here_yet",
|
||||||
|
"show_older",
|
||||||
|
"for_this_device",
|
||||||
|
"all_downloads",
|
||||||
|
"release_notes",
|
||||||
"upload_failed",
|
"upload_failed",
|
||||||
"upload_too_large",
|
"upload_too_large",
|
||||||
"upload_unavailable",
|
"upload_unavailable",
|
||||||
|
|||||||
+9
-6
@@ -31,15 +31,18 @@ pub mod people;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod seed;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod memberships;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod report;
|
|
||||||
/// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris).
|
/// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris).
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod lint;
|
pub mod lint;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod memberships;
|
||||||
|
/// The Apple configuration profile at `/profile.mobileconfig`.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod profile;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod report;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod seed;
|
||||||
/// The trainer: `portal plan`, `portal build`, `portal report` and the
|
/// The trainer: `portal plan`, `portal build`, `portal report` and the
|
||||||
/// rest (once uhhm/portal-trainer).
|
/// rest (once uhhm/portal-trainer).
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|||||||
+123
-25
@@ -8,7 +8,9 @@
|
|||||||
//! than compiled there - keeps that repo's CI coupling to "run a
|
//! than compiled there - keeps that repo's CI coupling to "run a
|
||||||
//! static binary," not "build a Rust workspace."
|
//! static binary," not "build a Rust workspace."
|
||||||
|
|
||||||
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir};
|
use crate::lint::local::{
|
||||||
|
load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir,
|
||||||
|
};
|
||||||
use crate::lint::needs;
|
use crate::lint::needs;
|
||||||
use crate::{aggregates, content};
|
use crate::{aggregates, content};
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
let mut skim = false;
|
let mut skim = false;
|
||||||
let mut sim_out: Option<String> = None;
|
let mut sim_out: Option<String> = None;
|
||||||
let mut show_access = false;
|
let mut show_access = false;
|
||||||
|
let mut findings_out: Option<String> = None;
|
||||||
|
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
@@ -35,9 +38,13 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
"--skim" => skim = true,
|
"--skim" => skim = true,
|
||||||
"--needs-sim" => sim_out = args.next(),
|
"--needs-sim" => sim_out = args.next(),
|
||||||
"--access" => show_access = true,
|
"--access" => show_access = true,
|
||||||
|
"--findings" => findings_out = args.next(),
|
||||||
"--needs-score" => answers_in = args.next(),
|
"--needs-score" => answers_in = args.next(),
|
||||||
"--min-accuracy" => {
|
"--min-accuracy" => {
|
||||||
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
min_accuracy = args
|
||||||
|
.next()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(min_accuracy)
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
eprintln!("unknown argument: {other}");
|
eprintln!("unknown argument: {other}");
|
||||||
@@ -56,23 +63,41 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
} else if let Some(repo_url) = repo {
|
} else if let Some(repo_url) = repo {
|
||||||
// The first storyline of each, as the checks read it; the
|
// The first storyline of each, as the checks read it; the
|
||||||
// loaders have held every list of texts to the site's arity.
|
// loaders have held every list of texts to the site's arity.
|
||||||
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?.at(0).clone();
|
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir)
|
||||||
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?.at(0).clone();
|
.await?
|
||||||
|
.at(0)
|
||||||
|
.clone();
|
||||||
|
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch)
|
||||||
|
.await?
|
||||||
|
.at(0)
|
||||||
|
.clone();
|
||||||
// load_site_from_gitea already validates; a missing file is
|
// load_site_from_gitea already validates; a missing file is
|
||||||
// the default config, same as at portal boot.
|
// the default config, same as at portal boot.
|
||||||
let site = content::load_site_from_gitea(&repo_url, &branch).await?.at(0).clone();
|
let site = content::load_site_from_gitea(&repo_url, &branch)
|
||||||
|
.await?
|
||||||
|
.at(0)
|
||||||
|
.clone();
|
||||||
// Optional: a fetch failure means the repo declares no needs.
|
// Optional: a fetch failure means the repo declares no needs.
|
||||||
let needs_raw = crate::http::get_text(&format!("{}/needs.yaml?ref={branch}", content::gitea_raw_base(&repo_url)?), crate::http::GITEA_CAP).await.ok();
|
let needs_raw = crate::http::get_text(
|
||||||
|
&format!(
|
||||||
|
"{}/needs.yaml?ref={branch}",
|
||||||
|
content::gitea_raw_base(&repo_url)?
|
||||||
|
),
|
||||||
|
crate::http::GITEA_CAP,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
(questions, aggregates_map, site, needs_raw)
|
(questions, aggregates_map, site, needs_raw)
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--findings <out.jsonl>] [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = site.validate() {
|
if let Err(e) = site.validate() {
|
||||||
eprintln!("FAIL: {e}");
|
eprintln!("FAIL: {e}");
|
||||||
|
write_findings(findings_out.as_deref(), &[schema_finding(&e.to_string())]);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
|
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
|
||||||
@@ -103,17 +128,29 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
eprintln!("FAIL: access policy does not validate: {e}");
|
eprintln!("FAIL: access policy does not validate: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
|
println!(
|
||||||
|
"OK: access policy, {} rule(s), validates",
|
||||||
|
policy.rules.len()
|
||||||
|
);
|
||||||
if show_access {
|
if show_access {
|
||||||
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
|
println!(
|
||||||
|
"\n{:<16} {:<44} {:<40} {}",
|
||||||
|
"ACTION", "RESOURCE", "WHO", "WHEN"
|
||||||
|
);
|
||||||
let mut rows = policy.rules.clone();
|
let mut rows = policy.rules.clone();
|
||||||
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
|
rows.sort_by(|a, b| {
|
||||||
|
(&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who))
|
||||||
|
});
|
||||||
for r in rows {
|
for r in rows {
|
||||||
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
|
println!(
|
||||||
|
"{:<16} {:<44} {:<40} {}",
|
||||||
|
r.action, r.resource, r.who, r.when
|
||||||
|
);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
check_needs(
|
check_needs(
|
||||||
|
findings_out.as_deref(),
|
||||||
needs_raw.as_deref(),
|
needs_raw.as_deref(),
|
||||||
&questions,
|
&questions,
|
||||||
&aggregates_map,
|
&aggregates_map,
|
||||||
@@ -126,15 +163,73 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("FAIL: {e}");
|
eprintln!("FAIL: {e}");
|
||||||
|
// One schema error is no reason to hide the rest: the needs
|
||||||
|
// pass reads the same content and says what else is wrong,
|
||||||
|
// so a fix round can take them all at once.
|
||||||
|
let mut all = vec![schema_finding(&e.to_string())];
|
||||||
|
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
|
||||||
|
let findings = needs::check(&file, &questions, &aggregates_map);
|
||||||
|
print_findings(&findings);
|
||||||
|
all.extend(findings);
|
||||||
|
}
|
||||||
|
write_findings(findings_out.as_deref(), &all);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A content error the schema pass stopped at, as a finding: the repo
|
||||||
|
/// is its scope and the message its subject, since it has no other.
|
||||||
|
fn schema_finding(message: &str) -> needs::Finding {
|
||||||
|
needs::Finding {
|
||||||
|
need: String::new(),
|
||||||
|
level: needs::Level::Fail,
|
||||||
|
message: message.to_string(),
|
||||||
|
scope: "repo",
|
||||||
|
code: "schema",
|
||||||
|
subject: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--findings`: every finding as one JSON line (id, level, code,
|
||||||
|
/// scope, need, subject, message, fix), the same ones the text output
|
||||||
|
/// prints - an empty file when there are none, so a reader can tell
|
||||||
|
/// "clean" from "not run".
|
||||||
|
fn write_findings(out: Option<&str>, findings: &[needs::Finding]) {
|
||||||
|
let Some(out) = out else { return };
|
||||||
|
let lines: String = findings
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("{}\n", f.to_json()))
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = std::fs::write(out, lines) {
|
||||||
|
eprintln!("WARN: could not write the findings to {out}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
|
||||||
|
fn print_findings(findings: &[needs::Finding]) -> usize {
|
||||||
|
let mut failed = 0;
|
||||||
|
for finding in findings {
|
||||||
|
let level = match finding.level {
|
||||||
|
needs::Level::Fail => {
|
||||||
|
failed += 1;
|
||||||
|
"FAIL"
|
||||||
|
}
|
||||||
|
needs::Level::Warn => "WARN",
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"{level}: {} {:?}: {}",
|
||||||
|
finding.scope, finding.need, finding.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
||||||
/// then the wording tasks written out and/or an engine's answers
|
/// then the wording tasks written out and/or an engine's answers
|
||||||
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
||||||
fn check_needs(
|
fn check_needs(
|
||||||
|
findings_out: Option<&str>,
|
||||||
raw: Option<&str>,
|
raw: Option<&str>,
|
||||||
questions: &std::collections::HashMap<String, content::Question>,
|
questions: &std::collections::HashMap<String, content::Question>,
|
||||||
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
||||||
@@ -145,6 +240,7 @@ fn check_needs(
|
|||||||
min_accuracy: f64,
|
min_accuracy: f64,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let Some(raw) = raw else {
|
let Some(raw) = raw else {
|
||||||
|
write_findings(findings_out, &[]);
|
||||||
if tasks_out.is_some() || answers_in.is_some() {
|
if tasks_out.is_some() || answers_in.is_some() {
|
||||||
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
|
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -155,28 +251,25 @@ fn check_needs(
|
|||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("FAIL: {e}");
|
eprintln!("FAIL: {e}");
|
||||||
|
write_findings(findings_out, &[schema_finding(&e.to_string())]);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let findings = needs::check(&file, questions, aggregates_map);
|
let findings = needs::check(&file, questions, aggregates_map);
|
||||||
let mut failed = 0;
|
let failed = print_findings(&findings);
|
||||||
for finding in &findings {
|
write_findings(findings_out, &findings);
|
||||||
let level = match finding.level {
|
|
||||||
needs::Level::Fail => {
|
|
||||||
failed += 1;
|
|
||||||
"FAIL"
|
|
||||||
}
|
|
||||||
needs::Level::Warn => "WARN",
|
|
||||||
};
|
|
||||||
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
|
|
||||||
}
|
|
||||||
for need in &file.needs {
|
for need in &file.needs {
|
||||||
if let Some(path) = needs::path_for(questions, need) {
|
if let Some(path) = needs::path_for(questions, need) {
|
||||||
let steps: Vec<String> = path
|
let steps: Vec<String> = path
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| format!("{} [{}]", s.page, s.alternative))
|
.map(|s| format!("{} [{}]", s.page, s.alternative))
|
||||||
.collect();
|
.collect();
|
||||||
println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in);
|
println!(
|
||||||
|
"need {:?}: {} -> {:?}",
|
||||||
|
need.id,
|
||||||
|
steps.join(" -> "),
|
||||||
|
need.lands_in
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if failed == 0 {
|
if failed == 0 {
|
||||||
@@ -210,9 +303,14 @@ fn check_needs(
|
|||||||
println!("wrote {} wording task(s) to {out}", tasks.len());
|
println!("wrote {} wording task(s) to {out}", tasks.len());
|
||||||
}
|
}
|
||||||
if let Some(path) = answers_in {
|
if let Some(path) = answers_in {
|
||||||
let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
let raw =
|
||||||
|
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||||
let mut answers = Vec::new();
|
let mut answers = Vec::new();
|
||||||
for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
for (n, line) in raw
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, l)| !l.trim().is_empty())
|
||||||
|
{
|
||||||
answers.push(
|
answers.push(
|
||||||
serde_json::from_str::<needs::Answer>(line)
|
serde_json::from_str::<needs::Answer>(line)
|
||||||
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
|
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,8 @@ pub fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
|
|||||||
// The first storyline, held to the arity (content::variants).
|
// The first storyline, held to the arity (content::variants).
|
||||||
let files = vec![("site.yaml".to_string(), raw)];
|
let files = vec![("site.yaml".to_string(), raw)];
|
||||||
content::arity(&files)?;
|
content::arity(&files)?;
|
||||||
serde_yaml::from_str(&content::storyline(&files, 0)[0].1).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
serde_yaml::from_str(&content::storyline(&files, 0)[0].1)
|
||||||
|
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
||||||
@@ -102,4 +103,3 @@ pub fn load_needs_from_dir(dir: &str) -> anyhow::Result<Option<String>> {
|
|||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
|
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,4 +50,3 @@ pub async fn run(mut argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+755
-55
File diff suppressed because it is too large
Load Diff
+104
-20
@@ -14,14 +14,19 @@
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
use crate::events::store::{ensure_stream, load_events};
|
use crate::events::store::{ensure_stream, load_events};
|
||||||
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
|
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
|
||||||
use crate::lint::needs;
|
use crate::lint::needs;
|
||||||
use crate::{aggregates, answers, content};
|
use crate::{aggregates, answers, content};
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||||
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
|
let value = |flag: &str| {
|
||||||
|
argv.iter()
|
||||||
|
.position(|a| a == flag)
|
||||||
|
.and_then(|i| argv.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
let usage = "usage: iris replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
|
let usage = "usage: iris replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
|
||||||
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
|
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
|
||||||
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
|
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
|
||||||
@@ -32,31 +37,71 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let questions = load_from_dir(&dir)?;
|
let questions = load_from_dir(&dir)?;
|
||||||
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
|
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
|
||||||
let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?;
|
let file = needs::parse(
|
||||||
|
&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?,
|
||||||
|
)?;
|
||||||
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
|
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
|
||||||
// The same call the site makes at boot.
|
// The same call the site makes at boot.
|
||||||
ensure_stream(&js).await?;
|
ensure_stream(&js).await?;
|
||||||
|
|
||||||
if let Some(path) = cases_path {
|
if let Some(path) = cases_path {
|
||||||
let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
let raw =
|
||||||
|
std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||||
let (mut created, mut moved, mut refused) = (0, 0, 0);
|
let (mut created, mut moved, mut refused) = (0, 0, 0);
|
||||||
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
|
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
|
||||||
let case: serde_json::Value = serde_json::from_str(line)?;
|
let case: serde_json::Value = serde_json::from_str(line)?;
|
||||||
let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue };
|
let Some(need) = file
|
||||||
let Some(schema) = schemas.get(&need.lands_in) else { continue };
|
.needs
|
||||||
|
.iter()
|
||||||
|
.find(|need| case["need"] == need.id.as_str())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(schema) = schemas.get(&need.lands_in) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// Only a case that got as far as submitting leaves a record.
|
// Only a case that got as far as submitting leaves a record.
|
||||||
let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect();
|
let states: Vec<&str> = case["states"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|s| s.as_str())
|
||||||
|
.collect();
|
||||||
if states.is_empty() {
|
if states.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
|
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
|
||||||
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
|
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
|
||||||
let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]);
|
let id = format!(
|
||||||
|
"sim-{}-{}-{n}",
|
||||||
|
case["persona"].as_str().unwrap_or("x"),
|
||||||
|
case["run"]
|
||||||
|
);
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now, 0).await?;
|
answers::store_answer(
|
||||||
|
&js,
|
||||||
|
&schemas,
|
||||||
|
&need.lands_in,
|
||||||
|
id.clone(),
|
||||||
|
&page,
|
||||||
|
&alternative,
|
||||||
|
&case["record"],
|
||||||
|
now,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
created += 1;
|
created += 1;
|
||||||
for target in &states[1..] {
|
for target in &states[1..] {
|
||||||
match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await {
|
match aggregates::transition(
|
||||||
|
&js,
|
||||||
|
schema,
|
||||||
|
&id,
|
||||||
|
target,
|
||||||
|
serde_json::json!({"by": "simulation"}),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => moved += 1,
|
Ok(_) => moved += 1,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
refused += 1;
|
refused += 1;
|
||||||
@@ -72,33 +117,72 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
// Read every bucket back the way the engine knows it: by replaying
|
// Read every bucket back the way the engine knows it: by replaying
|
||||||
// each record's own event log.
|
// each record's own event log.
|
||||||
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
|
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
|
||||||
for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::<std::collections::BTreeSet<_>>() {
|
for bucket in file
|
||||||
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue };
|
.needs
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.lands_in.clone())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
{
|
||||||
|
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let mut keys = store.keys().await?;
|
let mut keys = store.keys().await?;
|
||||||
while let Some(Ok(id)) = keys.next().await {
|
while let Some(Ok(id)) = keys.next().await {
|
||||||
let events = load_events(&js, &bucket, &id).await?;
|
let events = load_events(&js, &bucket, &id).await?;
|
||||||
if let Some(record) = aggregates::replay(schema, &id, &events) {
|
if let Some(record) = aggregates::replay(schema, &id, &events) {
|
||||||
*by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1;
|
*by_bucket
|
||||||
|
.entry(bucket.clone())
|
||||||
|
.or_default()
|
||||||
|
.entry(record.state)
|
||||||
|
.or_default() += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state");
|
println!(
|
||||||
|
"{:<18} {:>8} {:>9} {:>9} {}",
|
||||||
|
"bucket", "records", "finished", "success", "by state"
|
||||||
|
);
|
||||||
let mut buckets: Vec<_> = by_bucket.iter().collect();
|
let mut buckets: Vec<_> = by_bucket.iter().collect();
|
||||||
buckets.sort();
|
buckets.sort();
|
||||||
for (bucket, states) in buckets {
|
for (bucket, states) in buckets {
|
||||||
let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect();
|
let needs_here: Vec<&needs::Need> = file
|
||||||
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| needs_here.iter().any(|n| pick(n).contains(state));
|
.needs
|
||||||
|
.iter()
|
||||||
|
.filter(|n| &n.lands_in == bucket)
|
||||||
|
.collect();
|
||||||
|
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| {
|
||||||
|
needs_here.iter().any(|n| pick(n).contains(state))
|
||||||
|
};
|
||||||
let total: usize = states.values().sum();
|
let total: usize = states.values().sum();
|
||||||
let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum();
|
let finished: usize = states
|
||||||
let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum();
|
.iter()
|
||||||
let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) };
|
.filter(|(s, _)| is(|n| &n.done_when, s))
|
||||||
|
.map(|(_, c)| c)
|
||||||
|
.sum();
|
||||||
|
let success: usize = states
|
||||||
|
.iter()
|
||||||
|
.filter(|(s, _)| is(|n| &n.success, s))
|
||||||
|
.map(|(_, c)| c)
|
||||||
|
.sum();
|
||||||
|
let pct = |n: usize| {
|
||||||
|
if total == 0 {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:.0}%", n as f64 * 100.0 / total as f64)
|
||||||
|
}
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
"{:<18} {:>8} {:>9} {:>9} {}",
|
"{:<18} {:>8} {:>9} {:>9} {}",
|
||||||
bucket,
|
bucket,
|
||||||
total,
|
total,
|
||||||
pct(finished),
|
pct(finished),
|
||||||
pct(success),
|
pct(success),
|
||||||
states.iter().map(|(s, c)| format!("{s} {c}")).collect::<Vec<_>>().join(", ")
|
states
|
||||||
|
.iter()
|
||||||
|
.map(|(s, c)| format!("{s} {c}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+491
-78
@@ -74,7 +74,10 @@ pub struct Incoming {
|
|||||||
/// address can be forged. The sender must therefore also match the
|
/// address can be forged. The sender must therefore also match the
|
||||||
/// address the record already holds, which is the one portal wrote to
|
/// address the record already holds, which is the one portal wrote to
|
||||||
/// in the first place.
|
/// in the first place.
|
||||||
pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<crate::answers::Note, String> {
|
pub fn note_for(
|
||||||
|
incoming: &Incoming,
|
||||||
|
answer: &crate::answers::Answer,
|
||||||
|
) -> Result<crate::answers::Note, String> {
|
||||||
if incoming.v != CONTRACT {
|
if incoming.v != CONTRACT {
|
||||||
return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v));
|
return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v));
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,10 @@ pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<
|
|||||||
.filter(|e| !e.is_empty())
|
.filter(|e| !e.is_empty())
|
||||||
.ok_or_else(|| "the record holds no address to compare the sender with".to_string())?;
|
.ok_or_else(|| "the record holds no address to compare the sender with".to_string())?;
|
||||||
if address_of(&incoming.from) != held {
|
if address_of(&incoming.from) != held {
|
||||||
return Err(format!("{} is not the address this record was written to", incoming.from));
|
return Err(format!(
|
||||||
|
"{} is not the address this record was written to",
|
||||||
|
incoming.from
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if answer.notes.iter().any(|n| n.id == incoming.id) {
|
if answer.notes.iter().any(|n| n.id == incoming.id) {
|
||||||
return Err("this reply is already on the record".to_string());
|
return Err("this reply is already on the record".to_string());
|
||||||
@@ -128,7 +134,10 @@ pub async fn receive(state: crate::server::AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail");
|
tracing::info!(
|
||||||
|
subject = RECEIVED_SUBJECT,
|
||||||
|
"listening for replies to case mail"
|
||||||
|
);
|
||||||
while let Some(msg) = sub.next().await {
|
while let Some(msg) = sub.next().await {
|
||||||
let incoming: Incoming = match serde_json::from_slice(&msg.payload) {
|
let incoming: Incoming = match serde_json::from_slice(&msg.payload) {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
@@ -138,8 +147,12 @@ pub async fn receive(state: crate::server::AppState) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
match note_onto(&state, &incoming).await {
|
match note_onto(&state, &incoming).await {
|
||||||
Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"),
|
Ok(()) => {
|
||||||
Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"),
|
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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +160,9 @@ pub async fn receive(state: crate::server::AppState) {
|
|||||||
/// The record's own bucket is the only one a reply may touch, and the
|
/// 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.
|
/// note is appended under the same read-modify-write the desks use.
|
||||||
async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> {
|
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) {
|
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);
|
anyhow::bail!("{} is not a bucket of this site", incoming.bucket);
|
||||||
}
|
}
|
||||||
let store = state.jetstream.get_key_value(&incoming.bucket).await?;
|
let store = state.jetstream.get_key_value(&incoming.bucket).await?;
|
||||||
@@ -158,14 +173,18 @@ async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyh
|
|||||||
let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?;
|
let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?;
|
||||||
let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?;
|
let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?;
|
||||||
answer.notes.push(note);
|
answer.notes.push(note);
|
||||||
store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?;
|
store
|
||||||
|
.put(&incoming.record, serde_json::to_vec(&answer)?.into())
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idempotent, called by portal at boot and by gdo. A week of
|
/// Idempotent, called by portal at boot and by gdo. A week of
|
||||||
/// retention: a mail nobody delivered in a week is stale, not owed.
|
/// retention: a mail nobody delivered in a week is stale, not owed.
|
||||||
/// The duplicate window has to fit inside it.
|
/// The duplicate window has to fit inside it.
|
||||||
pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
pub async fn ensure_stream(
|
||||||
|
js: &async_nats::jetstream::Context,
|
||||||
|
) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
||||||
Ok(js
|
Ok(js
|
||||||
.get_or_create_stream(async_nats::jetstream::stream::Config {
|
.get_or_create_stream(async_nats::jetstream::stream::Config {
|
||||||
name: STREAM.to_string(),
|
name: STREAM.to_string(),
|
||||||
@@ -213,7 +232,8 @@ pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>)
|
|||||||
/// in the body is dropped, the same rule as page descriptions.
|
/// in the body is dropped, the same rule as page descriptions.
|
||||||
pub fn to_html(markdown: &str) -> String {
|
pub fn to_html(markdown: &str) -> String {
|
||||||
use pulldown_cmark::{html, Event, Options, Parser};
|
use pulldown_cmark::{html, Event, Options, Parser};
|
||||||
let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
|
let parser = Parser::new_ext(markdown, Options::empty())
|
||||||
|
.filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
|
||||||
let mut body = String::new();
|
let mut body = String::new();
|
||||||
html::push_html(&mut body, parser);
|
html::push_html(&mut body, parser);
|
||||||
format!(
|
format!(
|
||||||
@@ -221,28 +241,46 @@ pub fn to_html(markdown: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn public_url() -> String {
|
pub(crate) fn public_url() -> String {
|
||||||
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
|
std::env::var("PUBLIC_URL")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn site_name(site: &crate::content::SiteConfig) -> String {
|
fn site_name(site: &crate::content::SiteConfig) -> String {
|
||||||
site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_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,
|
/// The variables a state mail may use: every field of the record,
|
||||||
/// then the runtime's own.
|
/// 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> {
|
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();
|
let mut vars = serde_json::Map::new();
|
||||||
if let Some(fields) = responses.as_object() {
|
if let Some(fields) = responses.as_object() {
|
||||||
for (k, v) in fields {
|
for (k, v) in fields {
|
||||||
vars.insert(k.clone(), match v {
|
vars.insert(
|
||||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
k.clone(),
|
||||||
serde_json::Value::Null => serde_json::Value::String(String::new()),
|
match v {
|
||||||
other => serde_json::Value::String(other.to_string()),
|
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();
|
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("email".into(), email.into());
|
||||||
vars.insert("site".into(), public_url().into());
|
vars.insert("site".into(), public_url().into());
|
||||||
vars.insert("chain".into(), id.into());
|
vars.insert("chain".into(), id.into());
|
||||||
@@ -251,6 +289,85 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
|
|||||||
vars
|
vars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What every mail portal sends may say, whatever made it - a state
|
||||||
|
/// mail, an invite, a suggestion's answer: `{site}` (the instance's
|
||||||
|
/// public URL) and `{site_name}` (site.yaml's title). One place, so a
|
||||||
|
/// template written for one kind of mail reads the same in another
|
||||||
|
/// (docs/mail.md, Variables).
|
||||||
|
pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
vars.insert("site".into(), public_url().into());
|
||||||
|
vars.insert("site_name".into(), site_name(site).into());
|
||||||
|
vars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a mail about a record can send its reader (docs/mail.md):
|
||||||
|
///
|
||||||
|
/// - `{self_link}`: the record's own page for the person it belongs
|
||||||
|
/// to - a page offering a self-service move on this bucket from the
|
||||||
|
/// state the record is in, with the `?chain=&email=` that
|
||||||
|
/// lets them act without signing in;
|
||||||
|
/// - `{desk_link}`: the desk that moves the record - the page whose
|
||||||
|
/// list over the bucket carries transitions, else one that reads it;
|
||||||
|
/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}`
|
||||||
|
/// in one to anyone else, and the site itself when there is neither.
|
||||||
|
///
|
||||||
|
/// Empty strings, never missing keys, for a link the content has no
|
||||||
|
/// page for: a template naming one renders blank there rather than
|
||||||
|
/// with a broken URL.
|
||||||
|
pub fn record_links(
|
||||||
|
questions: &std::collections::HashMap<String, crate::content::Question>,
|
||||||
|
initial: &str,
|
||||||
|
bucket: &str,
|
||||||
|
id: &str,
|
||||||
|
state: &str,
|
||||||
|
email: &str,
|
||||||
|
to_submitter: bool,
|
||||||
|
) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let mut pages: Vec<&crate::content::Question> = questions.values().collect();
|
||||||
|
pages.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
// Only a move the engine would accept from where the record is:
|
||||||
|
// a link to a button that refuses is worse than none.
|
||||||
|
let self_page = pages.iter().find(|q| {
|
||||||
|
q.alternatives
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| a.self_transition.as_ref())
|
||||||
|
.any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state))
|
||||||
|
});
|
||||||
|
let reads = |q: &&crate::content::Question, moving: bool| {
|
||||||
|
q.alternatives
|
||||||
|
.iter()
|
||||||
|
.flat_map(|a| &a.features)
|
||||||
|
.filter_map(|f| f.resource.as_ref())
|
||||||
|
.any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty()))
|
||||||
|
};
|
||||||
|
let desk_page = pages
|
||||||
|
.iter()
|
||||||
|
.find(|q| reads(q, true))
|
||||||
|
.or_else(|| pages.iter().find(|q| reads(q, false)));
|
||||||
|
let site = public_url();
|
||||||
|
let self_link = match self_page {
|
||||||
|
Some(q) if email.contains('@') => {
|
||||||
|
let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect();
|
||||||
|
format!("{site}{}?chain={id}&email={email}", q.id)
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let desk_link = desk_page
|
||||||
|
.map(|q| format!("{site}{}", q.id))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) {
|
||||||
|
(true, false, _) => self_link.clone(),
|
||||||
|
(false, _, false) => desk_link.clone(),
|
||||||
|
_ => site,
|
||||||
|
};
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
vars.insert("self_link".into(), self_link.into());
|
||||||
|
vars.insert("desk_link".into(), desk_link.into());
|
||||||
|
vars.insert("link".into(), link.into());
|
||||||
|
vars
|
||||||
|
}
|
||||||
|
|
||||||
/// The record field a `to` reaches through, when it is neither
|
/// The record field a `to` reaches through, when it is neither
|
||||||
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||||
pub fn parent_of(to: &str) -> Option<&str> {
|
pub fn parent_of(to: &str) -> Option<&str> {
|
||||||
@@ -264,7 +381,12 @@ pub fn parent_of(to: &str) -> Option<&str> {
|
|||||||
/// `bucket`, the record it names, read from its own bucket. A
|
/// `bucket`, the record it names, read from its own bucket. A
|
||||||
/// reference that does not resolve adds nothing, and a template that
|
/// reference that does not resolve adds nothing, and a template that
|
||||||
/// needed it renders empty there.
|
/// 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>) {
|
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 questions = state.questions.load();
|
||||||
let refs: Vec<(String, String)> = questions
|
let refs: Vec<(String, String)> = questions
|
||||||
.values()
|
.values()
|
||||||
@@ -275,10 +397,22 @@ async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &
|
|||||||
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
|
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
|
||||||
.collect();
|
.collect();
|
||||||
for (field, of) in refs {
|
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 Some(id) = responses
|
||||||
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
|
.get(&field)
|
||||||
let Ok(Some(bytes)) = store.get(id).await else { continue };
|
.and_then(|v| v.as_str())
|
||||||
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
.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() {
|
if let Some(fields) = parent.responses.as_object() {
|
||||||
for (k, v) in fields {
|
for (k, v) in fields {
|
||||||
let text = match v {
|
let text = match v {
|
||||||
@@ -298,7 +432,9 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
|
|||||||
let mut headers = async_nats::HeaderMap::new();
|
let mut headers = async_nats::HeaderMap::new();
|
||||||
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
||||||
let bytes = serde_json::to_vec(mail)?;
|
let bytes = serde_json::to_vec(mail)?;
|
||||||
js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?;
|
js.publish_with_headers(SUBJECT, headers, bytes.into())
|
||||||
|
.await?
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,10 +442,24 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
|
|||||||
/// if anything. Best-effort by design - the transition is already
|
/// if anything. Best-effort by design - the transition is already
|
||||||
/// committed, and a mail that cannot be rendered is logged, never a
|
/// committed, and a mail that cannot be rendered is logged, never a
|
||||||
/// failed request.
|
/// failed request.
|
||||||
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) {
|
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.
|
// The mail tells the storyline the person read.
|
||||||
let storyline = storyline as usize;
|
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 {
|
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
|
// A suggestion has its own words for this unless the content
|
||||||
// gave the state a mail, which then is the one sent.
|
// gave the state a mail, which then is the one sent.
|
||||||
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
||||||
@@ -320,16 +470,54 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
|||||||
let site_storylines = state.site.load();
|
let site_storylines = state.site.load();
|
||||||
let site = site_storylines.at(storyline);
|
let site = site_storylines.at(storyline);
|
||||||
let Some(cfg) = &site.mail else {
|
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");
|
tracing::warn!(
|
||||||
|
bucket,
|
||||||
|
to_state,
|
||||||
|
"a mail is declared for this state, but site.yaml has no `mail.from` - not sent"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut vars = vars_for(bucket, id, to_state, responses);
|
let mut vars = vars_for(bucket, id, to_state, responses);
|
||||||
parent_vars(state, bucket, responses, &mut vars).await;
|
parent_vars(state, bucket, responses, &mut vars).await;
|
||||||
|
vars.extend(site_vars(site));
|
||||||
|
{
|
||||||
|
let email = vars
|
||||||
|
.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let questions = state.questions.load();
|
||||||
|
let initial = state
|
||||||
|
.aggregates
|
||||||
|
.load()
|
||||||
|
.at(storyline)
|
||||||
|
.get(bucket)
|
||||||
|
.map(|s| s.initial.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
vars.extend(record_links(
|
||||||
|
questions.at(storyline),
|
||||||
|
&initial,
|
||||||
|
bucket,
|
||||||
|
id,
|
||||||
|
to_state,
|
||||||
|
&email,
|
||||||
|
spec.to_submitter(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let to = if spec.to_submitter() {
|
let to = if spec.to_submitter() {
|
||||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
match vars
|
||||||
|
.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|e| e.contains('@'))
|
||||||
|
{
|
||||||
Some(e) => e.to_string(),
|
Some(e) => e.to_string(),
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent");
|
tracing::warn!(
|
||||||
|
bucket,
|
||||||
|
id,
|
||||||
|
to_state,
|
||||||
|
"mail to the submitter, but the record carries no email - not sent"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,14 +551,24 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
|||||||
/// quotes, and past the first three only how many more - someone who
|
/// quotes, and past the first three only how many more - someone who
|
||||||
/// answers for a whole site is not read its table of contents.
|
/// answers for a whole site is not read its table of contents.
|
||||||
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
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 quote = |q: &String| {
|
||||||
|
if norwegian {
|
||||||
|
format!("«{q}»")
|
||||||
|
} else {
|
||||||
|
format!("\u{201c}{q}\u{201d}")
|
||||||
|
}
|
||||||
|
};
|
||||||
let and = if norwegian { "og" } else { "and" };
|
let and = if norwegian { "og" } else { "and" };
|
||||||
match questions {
|
match questions {
|
||||||
[] => String::new(),
|
[] => String::new(),
|
||||||
[one] => quote(one),
|
[one] => quote(one),
|
||||||
few if few.len() <= 3 => {
|
few if few.len() <= 3 => {
|
||||||
let quoted: Vec<String> = few.iter().map(quote).collect();
|
let quoted: Vec<String> = few.iter().map(quote).collect();
|
||||||
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
|
format!(
|
||||||
|
"{} {and} {}",
|
||||||
|
quoted[..quoted.len() - 1].join(", "),
|
||||||
|
quoted[quoted.len() - 1]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
many => {
|
many => {
|
||||||
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
|
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
|
||||||
@@ -390,13 +588,20 @@ pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
|||||||
/// a question and they had no account: who they are to the site (the
|
/// 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
|
/// 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.
|
/// 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]) {
|
pub async fn on_responsible(
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
invited: &crate::people::Invited,
|
||||||
|
questions: &[String],
|
||||||
|
) {
|
||||||
let site = state.site.load();
|
let site = state.site.load();
|
||||||
let Some(cfg) = &site.mail else {
|
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");
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
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 list = questions_listed(questions, norwegian);
|
||||||
let (subject, body) = if norwegian {
|
let (subject, body) = if norwegian {
|
||||||
(
|
(
|
||||||
@@ -419,7 +624,9 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
|||||||
// The form's own fields first, so the invite's fixed placeholders
|
// The form's own fields first, so the invite's fixed placeholders
|
||||||
// win a clash; a date and time reads as one.
|
// win a clash; a date and time reads as one.
|
||||||
for (key, value) in &invited.fields {
|
for (key, value) in &invited.fields {
|
||||||
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
vars.insert(key.clone(), text.into());
|
vars.insert(key.clone(), text.into());
|
||||||
}
|
}
|
||||||
@@ -427,9 +634,11 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
|||||||
vars.insert("name".into(), invited.display_name.clone().into());
|
vars.insert("name".into(), invited.display_name.clone().into());
|
||||||
vars.insert("username".into(), invited.username.clone().into());
|
vars.insert("username".into(), invited.username.clone().into());
|
||||||
vars.insert("questions".into(), list.into());
|
vars.insert("questions".into(), list.into());
|
||||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
vars.extend(site_vars(&site));
|
||||||
vars.insert("site".into(), public_url().into());
|
vars.insert(
|
||||||
vars.insert("site_name".into(), site_name(&site).into());
|
"link".into(),
|
||||||
|
invited.reset_link.clone().unwrap_or_default().into(),
|
||||||
|
);
|
||||||
vars.insert("expires".into(), expires.into());
|
vars.insert("expires".into(), expires.into());
|
||||||
let text = render(&body, &vars);
|
let text = render(&body, &vars);
|
||||||
let mail = Outgoing {
|
let mail = Outgoing {
|
||||||
@@ -484,32 +693,65 @@ pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str
|
|||||||
|
|
||||||
/// A suggestion moved: tell whoever made it. Replies go to the site's
|
/// A suggestion moved: tell whoever made it. Replies go to the site's
|
||||||
/// inbox, which is who decided.
|
/// inbox, which is who decided.
|
||||||
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
|
async fn on_suggestion_state(
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
id: &str,
|
||||||
|
to_state: &str,
|
||||||
|
responses: &serde_json::Value,
|
||||||
|
) {
|
||||||
let site = state.site.load();
|
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 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 {
|
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(cfg) = &site.mail else {
|
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");
|
tracing::warn!(
|
||||||
|
id,
|
||||||
|
to_state,
|
||||||
|
"a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
let field = |name: &str| {
|
||||||
|
responses
|
||||||
|
.get(name)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
let to = field("email");
|
let to = field("email");
|
||||||
if !to.contains('@') {
|
if !to.contains('@') {
|
||||||
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
|
tracing::warn!(
|
||||||
|
id,
|
||||||
|
to_state,
|
||||||
|
"a suggestion moved, but the record carries no email - not sent"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
let quoted: String = field("text")
|
||||||
let mut vars = serde_json::Map::new();
|
.lines()
|
||||||
|
.map(|l| format!("> {l}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let mut vars = site_vars(&site);
|
||||||
vars.insert("name".into(), field("name").into());
|
vars.insert("name".into(), field("name").into());
|
||||||
vars.insert("question".into(), field("question").into());
|
vars.insert("question".into(), field("question").into());
|
||||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
vars.insert(
|
||||||
|
"page".into(),
|
||||||
|
format!("{}{}", public_url(), field("page")).into(),
|
||||||
|
);
|
||||||
vars.insert("quoted".into(), quoted.into());
|
vars.insert("quoted".into(), quoted.into());
|
||||||
let text = render(body, &vars);
|
let text = render(body, &vars);
|
||||||
let mail = Outgoing {
|
let mail = Outgoing {
|
||||||
v: CONTRACT,
|
v: CONTRACT,
|
||||||
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
|
id: format!(
|
||||||
|
"{}:{id}:{to_state}",
|
||||||
|
crate::content::EDIT_SUGGESTIONS_BUCKET
|
||||||
|
),
|
||||||
site: site_name(&site),
|
site: site_name(&site),
|
||||||
from: cfg.from.clone(),
|
from: cfg.from.clone(),
|
||||||
reply_to: cfg.reply_to.clone(),
|
reply_to: cfg.reply_to.clone(),
|
||||||
@@ -526,14 +768,24 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
|
|||||||
/// A change suggested by the person responsible for a page, to the
|
/// A change suggested by the person responsible for a page, to the
|
||||||
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
|
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
|
||||||
/// suggester as reply-to so answering the mail answers them.
|
/// 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) {
|
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 site = state.site.load();
|
||||||
let Some(cfg) = &site.mail else {
|
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");
|
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
|
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 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 {
|
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()))
|
(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 {
|
} else {
|
||||||
@@ -570,13 +822,63 @@ pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
|
|||||||
let day = t.weekday().num_days_from_monday() as usize;
|
let day = t.weekday().num_days_from_monday() as usize;
|
||||||
let month = t.month0() as usize;
|
let month = t.month0() as usize;
|
||||||
Some(if norwegian {
|
Some(if norwegian {
|
||||||
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
|
const DAYS: [&str; 7] = [
|
||||||
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
|
"mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag",
|
||||||
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
];
|
||||||
|
const MONTHS: [&str; 12] = [
|
||||||
|
"januar",
|
||||||
|
"februar",
|
||||||
|
"mars",
|
||||||
|
"april",
|
||||||
|
"mai",
|
||||||
|
"juni",
|
||||||
|
"juli",
|
||||||
|
"august",
|
||||||
|
"september",
|
||||||
|
"oktober",
|
||||||
|
"november",
|
||||||
|
"desember",
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"{} {}. {} kl. {:02}:{:02}",
|
||||||
|
DAYS[day],
|
||||||
|
t.day(),
|
||||||
|
MONTHS[month],
|
||||||
|
t.hour(),
|
||||||
|
t.minute()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
const DAYS: [&str; 7] = [
|
||||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
"Monday",
|
||||||
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
"Sunday",
|
||||||
|
];
|
||||||
|
const MONTHS: [&str; 12] = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"{} {} {} at {:02}:{:02}",
|
||||||
|
DAYS[day],
|
||||||
|
t.day(),
|
||||||
|
MONTHS[month],
|
||||||
|
t.hour(),
|
||||||
|
t.minute()
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +888,10 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
|
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
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 (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
|
let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
|
||||||
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||||
(None, true, true) => (
|
(None, true, true) => (
|
||||||
@@ -617,9 +922,11 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
vars.insert("username".into(), invited.username.clone().into());
|
vars.insert("username".into(), invited.username.clone().into());
|
||||||
vars.insert("email".into(), invited.email.clone().into());
|
vars.insert("email".into(), invited.email.clone().into());
|
||||||
vars.insert("group".into(), invited.group.clone().into());
|
vars.insert("group".into(), invited.group.clone().into());
|
||||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
vars.extend(site_vars(&site));
|
||||||
vars.insert("site".into(), public_url().into());
|
vars.insert(
|
||||||
vars.insert("site_name".into(), site_name(&site).into());
|
"link".into(),
|
||||||
|
invited.reset_link.clone().unwrap_or_default().into(),
|
||||||
|
);
|
||||||
vars.insert("expires".into(), expires.into());
|
vars.insert("expires".into(), expires.into());
|
||||||
let text = render(&body, &vars);
|
let text = render(&body, &vars);
|
||||||
let mail = Outgoing {
|
let mail = Outgoing {
|
||||||
@@ -640,6 +947,57 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_mail_links_its_reader_to_where_they_act() {
|
||||||
|
let files = vec![
|
||||||
|
("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()),
|
||||||
|
("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()),
|
||||||
|
("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()),
|
||||||
|
("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()),
|
||||||
|
];
|
||||||
|
let questions = crate::content::build_questions(&files).unwrap();
|
||||||
|
let site = public_url();
|
||||||
|
let to_tenant = record_links(
|
||||||
|
&questions,
|
||||||
|
"open",
|
||||||
|
"feil",
|
||||||
|
"abc",
|
||||||
|
"befaring",
|
||||||
|
"kari@x.no",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
to_tenant["self_link"],
|
||||||
|
format!("{site}/trekk?chain=abc&email=kari%40x.no")
|
||||||
|
);
|
||||||
|
assert_eq!(to_tenant["desk_link"], format!("{site}/styret"));
|
||||||
|
assert_eq!(to_tenant["link"], to_tenant["self_link"]);
|
||||||
|
let to_board = record_links(
|
||||||
|
&questions,
|
||||||
|
"open",
|
||||||
|
"feil",
|
||||||
|
"abc",
|
||||||
|
"befaring",
|
||||||
|
"kari@x.no",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(to_board["link"], to_board["desk_link"]);
|
||||||
|
// Past where the self-service move can be made: the person has
|
||||||
|
// no page of their own, so `{link}` is the site.
|
||||||
|
let after = record_links(
|
||||||
|
&questions,
|
||||||
|
"open",
|
||||||
|
"feil",
|
||||||
|
"abc",
|
||||||
|
"utbedret",
|
||||||
|
"kari@x.no",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert_eq!(after["self_link"], "");
|
||||||
|
assert_eq!(after["link"], site);
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -648,10 +1006,22 @@ mod tests {
|
|||||||
assert_eq!(questions_listed(&q(0), true), "");
|
assert_eq!(questions_listed(&q(0), true), "");
|
||||||
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
|
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
|
||||||
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
|
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!(
|
||||||
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
|
questions_listed(&q(4), true),
|
||||||
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
|
"«Q1?», «Q2?», «Q3?» og ett spørsmål til"
|
||||||
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");
|
);
|
||||||
|
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]
|
#[test]
|
||||||
@@ -659,8 +1029,14 @@ mod tests {
|
|||||||
for norwegian in [true, false] {
|
for norwegian in [true, false] {
|
||||||
for state in ["discussed", "done", "declined"] {
|
for state in ["discussed", "done", "declined"] {
|
||||||
let (subject, body) = suggestion_words(state, norwegian).expect(state);
|
let (subject, body) = suggestion_words(state, norwegian).expect(state);
|
||||||
assert!(subject.contains("{question}"), "{state}: the subject names the question");
|
assert!(
|
||||||
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
|
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.
|
// Arriving is told to the inbox, not back to who sent it.
|
||||||
assert!(suggestion_words("open", norwegian).is_none());
|
assert!(suggestion_words("open", norwegian).is_none());
|
||||||
@@ -681,18 +1057,34 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn templates_take_fields_and_leave_stray_braces_alone() {
|
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 responses =
|
||||||
|
serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
|
||||||
let vars = vars_for("reports", "abc", "fixed", &responses);
|
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!(
|
||||||
assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {");
|
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["bucket"], "reports");
|
||||||
assert_eq!(vars["state"], "fixed");
|
assert_eq!(vars["state"], "fixed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
|
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
|
||||||
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
|
assert_eq!(
|
||||||
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
|
spoken_datetime("2026-10-06T15:00", true).as_deref(),
|
||||||
|
Some("tirsdag 6. oktober kl. 15:00")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spoken_datetime("2026-10-06T15:00", false).as_deref(),
|
||||||
|
Some("Tuesday 6 October at 15:00")
|
||||||
|
);
|
||||||
assert_eq!(spoken_datetime("Anders Kluge", true), None);
|
assert_eq!(spoken_datetime("Anders Kluge", true), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +1120,9 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(older.subject, "s");
|
assert_eq!(older.subject, "s");
|
||||||
assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\""));
|
assert!(serde_json::to_string(&older)
|
||||||
|
.unwrap()
|
||||||
|
.contains("\"html\":\"h\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record(email: &str) -> crate::answers::Answer {
|
fn record(email: &str) -> crate::answers::Answer {
|
||||||
@@ -743,6 +1137,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +1156,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_reply_from_the_address_on_the_record_becomes_a_note() {
|
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();
|
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.from, "kari@x.no");
|
||||||
assert_eq!(note.text, "It is fixed now.");
|
assert_eq!(note.text, "It is fixed now.");
|
||||||
assert_eq!(note.id, "mail-1");
|
assert_eq!(note.id, "mail-1");
|
||||||
@@ -772,21 +1171,33 @@ mod tests {
|
|||||||
fn a_reply_from_anyone_else_is_refused() {
|
fn a_reply_from_anyone_else_is_refused() {
|
||||||
// The address a reply arrives on is unguessable, but a From is
|
// The address a reply arrives on is unguessable, but a From is
|
||||||
// a claim, and a mailbox is a door anyone may knock on.
|
// 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();
|
let err =
|
||||||
assert!(err.contains("not the address this record was written to"), "{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.
|
// Nothing to compare against is also a refusal, not a pass.
|
||||||
let mut anonymous = record("kari@x.no");
|
let mut anonymous = record("kari@x.no");
|
||||||
anonymous.responses = serde_json::json!({ "where": "The bend" });
|
anonymous.responses = serde_json::json!({ "where": "The bend" });
|
||||||
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address"));
|
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("no address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_reply_and_a_repeated_one_add_nothing() {
|
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"));
|
assert!(
|
||||||
|
note_for(&reply("kari@x.no", " \n "), &record("kari@x.no"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("no text")
|
||||||
|
);
|
||||||
|
|
||||||
let mut already = record("kari@x.no");
|
let mut already = record("kari@x.no");
|
||||||
already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap());
|
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();
|
let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err();
|
||||||
assert!(err.contains("already on the record"), "{err}");
|
assert!(err.contains("already on the record"), "{err}");
|
||||||
}
|
}
|
||||||
@@ -795,7 +1206,9 @@ mod tests {
|
|||||||
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() {
|
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() {
|
||||||
let mut future = reply("kari@x.no", "hello");
|
let mut future = reply("kari@x.no", "hello");
|
||||||
future.v = CONTRACT + 1;
|
future.v = CONTRACT + 1;
|
||||||
assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract"));
|
assert!(note_for(&future, &record("kari@x.no"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("contract"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+50
-19
@@ -27,7 +27,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
async fn serve() -> anyhow::Result<()> {
|
async fn serve() -> anyhow::Result<()> {
|
||||||
use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::Request,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{any, get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
use portal::app::{shell, App};
|
use portal::app::{shell, App};
|
||||||
@@ -57,12 +64,15 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
let gitea_base = content::gitea_api_base(&content_repo)?;
|
let gitea_base = content::gitea_api_base(&content_repo)?;
|
||||||
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
||||||
let 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?;
|
let questions =
|
||||||
|
content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||||
content::validate_questions(&questions, &aggregates)?;
|
content::validate_questions(&questions, &aggregates)?;
|
||||||
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
||||||
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?;
|
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");
|
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 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 questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
|
||||||
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
||||||
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
|
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
|
||||||
@@ -128,7 +138,12 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// The same decisions, answerable by other apps over NATS, for a
|
// The same decisions, answerable by other apps over NATS, for a
|
||||||
// person named by phone number or address as well as by groups.
|
// 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())));
|
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
|
// Announced pages: keep their desk records current, close ended
|
||||||
// windows (see announce.rs).
|
// windows (see announce.rs).
|
||||||
tokio::spawn(portal::announce::run(state.clone()));
|
tokio::spawn(portal::announce::run(state.clone()));
|
||||||
@@ -202,18 +217,17 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
});
|
});
|
||||||
match &governor {
|
match &governor {
|
||||||
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
||||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
|
None => tracing::warn!(
|
||||||
|
"rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn server_fn_handler(
|
async fn server_fn_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
request: Request<Body>,
|
request: Request<Body>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
leptos_axum::handle_server_fns_with_context(
|
leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request)
|
||||||
move || provide_context(state.clone()),
|
.await
|
||||||
request,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The app's own Leptos route list includes a root-level wildcard
|
// The app's own Leptos route list includes a root-level wildcard
|
||||||
@@ -264,10 +278,7 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
||||||
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
||||||
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
|
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
|
||||||
.route_service(
|
.route_service("/swiper-element-bundle.min.js", ServeFile::new(swiper_path))
|
||||||
"/swiper-element-bundle.min.js",
|
|
||||||
ServeFile::new(swiper_path),
|
|
||||||
)
|
|
||||||
// Same name every release, so revalidated like /pkg.
|
// Same name every release, so revalidated like /pkg.
|
||||||
.route_service(
|
.route_service(
|
||||||
"/page-motion.js",
|
"/page-motion.js",
|
||||||
@@ -291,7 +302,9 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
// with room for the multipart framing.
|
// with room for the multipart framing.
|
||||||
.route(
|
.route(
|
||||||
"/upload",
|
"/upload",
|
||||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(
|
||||||
|
upload::MAX_UPLOAD_BYTES + 64 * 1024,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/attachment/{bucket}/{record}/{field}",
|
"/attachment/{bucket}/{record}/{field}",
|
||||||
@@ -302,7 +315,12 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
.route("/report", get(portal::report::handler))
|
.route("/report", get(portal::report::handler))
|
||||||
// Content-shipped assets (hero module etc.), same-origin.
|
// Content-shipped assets (hero module etc.), same-origin.
|
||||||
.route("/site/{*path}", get(content::site_asset_handler))
|
.route("/site/{*path}", get(content::site_asset_handler))
|
||||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
.route("/.well-known/{*path}", get(content::well_known_handler))
|
||||||
|
.route("/profile.mobileconfig", get(portal::profile::handler))
|
||||||
|
.route(
|
||||||
|
"/automation/kv/{bucket}",
|
||||||
|
get(content::automation_kv_handler),
|
||||||
|
)
|
||||||
// Maps, proxied: the browser asks this origin for every tile
|
// Maps, proxied: the browser asks this origin for every tile
|
||||||
// and every still, so nothing third-party loads in a page and
|
// and every still, so nothing third-party loads in a page and
|
||||||
// the token stays here (see maps.rs).
|
// the token stays here (see maps.rs).
|
||||||
@@ -324,7 +342,10 @@ async fn serve() -> anyhow::Result<()> {
|
|||||||
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||||
.layer(session_layer)
|
.layer(session_layer)
|
||||||
// Which storyline this visitor reads, before anything renders.
|
// Which storyline this visitor reads, before anything renders.
|
||||||
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
|
.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
|
// Outermost, so a caller who is over the limit is turned away
|
||||||
// before a session is loaded or a handler is entered.
|
// before a session is loaded or a handler is entered.
|
||||||
let app = match governor {
|
let app = match governor {
|
||||||
@@ -362,7 +383,17 @@ fn site_name_for_subjects() -> String {
|
|||||||
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
|
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
|
||||||
let folded: String = raw
|
let folded: String = raw
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
||||||
|
c.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if folded.is_empty() { "portal".to_string() } else { folded }
|
if folded.is_empty() {
|
||||||
|
"portal".to_string()
|
||||||
|
} else {
|
||||||
|
folded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-15
@@ -42,7 +42,11 @@ fn style_id() -> String {
|
|||||||
let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else {
|
let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
[owner, id].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
|
[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())
|
.unwrap_or_else(|| "mapbox/streets-v12".to_string())
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,9 @@ fn style_id() -> String {
|
|||||||
/// The instance's Mapbox token, or `None` when maps are off.
|
/// The instance's Mapbox token, or `None` when maps are off.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub fn token() -> Option<String> {
|
pub fn token() -> Option<String> {
|
||||||
std::env::var("MAPBOX_TOKEN").ok().filter(|t| !t.trim().is_empty())
|
std::env::var("MAPBOX_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
@@ -64,7 +70,11 @@ pub fn enabled() -> bool {
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn style() -> Response {
|
pub async fn style() -> Response {
|
||||||
if !enabled() {
|
if !enabled() {
|
||||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"maps are not configured on this instance",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
let style = serde_json::json!({
|
let style = serde_json::json!({
|
||||||
"version": 8,
|
"version": 8,
|
||||||
@@ -79,7 +89,10 @@ pub async fn style() -> Response {
|
|||||||
"layers": [{ "id": "base", "type": "raster", "source": "base" }],
|
"layers": [{ "id": "base", "type": "raster", "source": "base" }],
|
||||||
});
|
});
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")],
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/json"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=3600"),
|
||||||
|
],
|
||||||
style.to_string(),
|
style.to_string(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -90,7 +103,11 @@ pub async fn style() -> Response {
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
|
pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
|
||||||
let Some(token) = token() else {
|
let Some(token) = token() else {
|
||||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
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) {
|
if z > 22 || x >= 1 << z.min(22) || y >= 1 << z.min(22) {
|
||||||
return (StatusCode::BAD_REQUEST, "no such tile").into_response();
|
return (StatusCode::BAD_REQUEST, "no such tile").into_response();
|
||||||
@@ -108,13 +125,21 @@ pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
|
pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
|
||||||
let Some(token) = token() else {
|
let Some(token) = token() else {
|
||||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"maps are not configured on this instance",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
let Some((lon, lat, zoom)) = parse_place(&place) else {
|
let Some((lon, lat, zoom)) = parse_place(&place) else {
|
||||||
return (StatusCode::BAD_REQUEST, "expected <lon>,<lat>,<zoom>").into_response();
|
return (StatusCode::BAD_REQUEST, "expected <lon>,<lat>,<zoom>").into_response();
|
||||||
};
|
};
|
||||||
let Some((w, h)) = parse_size(&size) else {
|
let Some((w, h)) = parse_size(&size) else {
|
||||||
return (StatusCode::BAD_REQUEST, "expected <width>x<height>, at most 1280").into_response();
|
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
|
// The pin is drawn by Mapbox so the image is self-contained; the
|
||||||
// marker colour is portal's ink, not a brand colour.
|
// marker colour is portal's ink, not a brand colour.
|
||||||
@@ -127,7 +152,12 @@ pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
|
|||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
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()) {
|
let resp = match crate::http::client()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.and_then(|r| r.error_for_status())
|
||||||
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("map upstream: {e}");
|
tracing::warn!("map upstream: {e}");
|
||||||
@@ -143,7 +173,10 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
|||||||
match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
|
match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
|
||||||
Ok(bytes) => (
|
Ok(bytes) => (
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())),
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
upstream.unwrap_or_else(|| mime.to_string()),
|
||||||
|
),
|
||||||
(header::CACHE_CONTROL, cache.to_string()),
|
(header::CACHE_CONTROL, cache.to_string()),
|
||||||
],
|
],
|
||||||
axum::body::Bytes::from(bytes),
|
axum::body::Bytes::from(bytes),
|
||||||
@@ -159,16 +192,25 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
fn parse_place(place: &str) -> Option<(String, String, String)> {
|
fn parse_place(place: &str) -> Option<(String, String, String)> {
|
||||||
let mut parts = place.split(',');
|
let mut parts = place.split(',');
|
||||||
let (Some(lon), Some(lat), Some(zoom), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
|
let (Some(lon), Some(lat), Some(zoom), None) =
|
||||||
|
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||||
|
else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let lon: f64 = lon.parse().ok()?;
|
let lon: f64 = lon.parse().ok()?;
|
||||||
let lat: f64 = lat.parse().ok()?;
|
let lat: f64 = lat.parse().ok()?;
|
||||||
let zoom: f64 = zoom.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) {
|
if !(-180.0..=180.0).contains(&lon)
|
||||||
|
|| !(-85.0511..=85.0511).contains(&lat)
|
||||||
|
|| !(0.0..=22.0).contains(&zoom)
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((format!("{lon:.5}"), format!("{lat:.5}"), format!("{zoom:.2}")))
|
Some((
|
||||||
|
format!("{lon:.5}"),
|
||||||
|
format!("{lat:.5}"),
|
||||||
|
format!("{zoom:.2}"),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
@@ -203,7 +245,15 @@ mod tests {
|
|||||||
parse_place("11.123456,59.987654,13.5"),
|
parse_place("11.123456,59.987654,13.5"),
|
||||||
Some(("11.12346".into(), "59.98765".into(), "13.50".into()))
|
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"] {
|
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}");
|
assert_eq!(parse_place(bad), None, "{bad}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +262,15 @@ mod tests {
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
fn a_size_is_two_numbers_within_what_the_service_draws() {
|
fn a_size_is_two_numbers_within_what_the_service_draws() {
|
||||||
assert_eq!(parse_size("600x300"), Some((600, 300)));
|
assert_eq!(parse_size("600x300"), Some((600, 300)));
|
||||||
for bad in ["600", "0x300", "600x0", "2000x300", "600x2000", "600x300x2", "sixx300"] {
|
for bad in [
|
||||||
|
"600",
|
||||||
|
"0x300",
|
||||||
|
"600x0",
|
||||||
|
"2000x300",
|
||||||
|
"600x2000",
|
||||||
|
"600x300x2",
|
||||||
|
"sixx300",
|
||||||
|
] {
|
||||||
assert_eq!(parse_size(bad), None, "{bad}");
|
assert_eq!(parse_size(bad), None, "{bad}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,7 +279,10 @@ mod tests {
|
|||||||
fn a_location_is_a_lat_and_a_lon_and_a_zoom_it_may_leave_out() {
|
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 });
|
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(&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)));
|
assert_eq!(
|
||||||
|
read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })),
|
||||||
|
Some((59.5, 11.0, 13.0))
|
||||||
|
);
|
||||||
for not in [
|
for not in [
|
||||||
serde_json::json!({ "lat": 91.0, "lon": 11.0 }),
|
serde_json::json!({ "lat": 91.0, "lon": 11.0 }),
|
||||||
serde_json::json!({ "lat": 59.5 }),
|
serde_json::json!({ "lat": 59.5 }),
|
||||||
|
|||||||
+219
-45
@@ -32,7 +32,10 @@ pub const BY: &str = "portal";
|
|||||||
|
|
||||||
/// The country a phone number without one is in, as a calling code.
|
/// The country a phone number without one is in, as a calling code.
|
||||||
fn default_country() -> String {
|
fn default_country() -> String {
|
||||||
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_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.
|
/// Who a person is, as far as a record or a session knows.
|
||||||
@@ -69,7 +72,11 @@ pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_email(raw: &str) -> Option<String> {
|
fn normalize_email(raw: &str) -> Option<String> {
|
||||||
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
let e = raw
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("mailto:")
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +95,8 @@ impl Identity {
|
|||||||
let country = default_country();
|
let country = default_country();
|
||||||
Identity {
|
Identity {
|
||||||
sub: None,
|
sub: None,
|
||||||
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
|
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)),
|
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +105,10 @@ impl Identity {
|
|||||||
let country = default_country();
|
let country = default_country();
|
||||||
Identity {
|
Identity {
|
||||||
sub: Some(user.sub.clone()),
|
sub: Some(user.sub.clone()),
|
||||||
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
|
phone: user
|
||||||
|
.phone
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| normalize_phone(p, &country)),
|
||||||
email: user.email.as_deref().and_then(normalize_email),
|
email: user.email.as_deref().and_then(normalize_email),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +118,16 @@ impl Identity {
|
|||||||
pub fn from_contact(contact: &str) -> Self {
|
pub fn from_contact(contact: &str) -> Self {
|
||||||
let country = default_country();
|
let country = default_country();
|
||||||
match normalize_email(contact) {
|
match normalize_email(contact) {
|
||||||
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
|
Some(email) => Identity {
|
||||||
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
|
sub: None,
|
||||||
|
phone: None,
|
||||||
|
email: Some(email),
|
||||||
|
},
|
||||||
|
None => Identity {
|
||||||
|
sub: None,
|
||||||
|
phone: normalize_phone(contact, &country),
|
||||||
|
email: None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +139,26 @@ impl Identity {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.or(self.email.as_deref())
|
.or(self.email.as_deref())
|
||||||
.or(self.sub.as_deref())
|
.or(self.sub.as_deref())
|
||||||
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
|
.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
|
/// Whether a record about `other` is about this person: the same
|
||||||
/// subject, phone number or address, any one of them.
|
/// subject, phone number or address, any one of them.
|
||||||
pub fn matches(&self, other: &Identity) -> bool {
|
pub fn matches(&self, other: &Identity) -> bool {
|
||||||
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
|
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)
|
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
|
/// What is worth writing on a record from a login: everything the
|
||||||
@@ -149,14 +180,34 @@ pub fn record_id(group: &str, key: &str) -> String {
|
|||||||
/// The identity a record holds.
|
/// The identity a record holds.
|
||||||
pub fn identity_of(answer: &Answer) -> Identity {
|
pub fn identity_of(answer: &Answer) -> Identity {
|
||||||
Identity {
|
Identity {
|
||||||
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
sub: answer
|
||||||
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
.responses
|
||||||
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
.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 {
|
pub fn group_of(answer: &Answer) -> String {
|
||||||
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_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.
|
/// The groups `who` is an active member of, from every record.
|
||||||
@@ -172,19 +223,26 @@ pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
|
|||||||
groups
|
groups
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
async fn store(
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
||||||
match state.jetstream.get_key_value(BUCKET).await {
|
match state.jetstream.get_key_value(BUCKET).await {
|
||||||
Ok(store) => Ok(store),
|
Ok(store) => Ok(store),
|
||||||
Err(_) => Ok(state
|
Err(_) => Ok(state
|
||||||
.jetstream
|
.jetstream
|
||||||
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
|
.create_key_value(async_nats::jetstream::kv::Config {
|
||||||
|
bucket: BUCKET.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
.await?),
|
.await?),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every membership record.
|
/// Every membership record.
|
||||||
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
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 Ok(store) = state.jetstream.get_key_value(BUCKET).await else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let keys: Vec<String> = {
|
let keys: Vec<String> = {
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
match store.keys().await {
|
match store.keys().await {
|
||||||
@@ -205,18 +263,36 @@ pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
|||||||
|
|
||||||
/// Moves one record the way a desk button would: through the event
|
/// Moves one record the way a desk button would: through the event
|
||||||
/// log, onto the projection, out on the answers subject.
|
/// 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> {
|
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 aggregates = state.aggregates.load();
|
||||||
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
let schema = aggregates
|
||||||
|
.get(BUCKET)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
|
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)
|
crate::answers::transition_or_reseed(
|
||||||
.await
|
&state.jetstream,
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
schema,
|
||||||
|
&answer.id,
|
||||||
|
target,
|
||||||
|
&answer.state,
|
||||||
|
payload.clone(),
|
||||||
|
now_ms,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
answer.state = target.to_string();
|
answer.state = target.to_string();
|
||||||
answer.decided_ms = Some(now_ms);
|
answer.decided_ms = Some(now_ms);
|
||||||
answer.decided_by = Some(by.to_string());
|
answer.decided_by = Some(by.to_string());
|
||||||
store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?;
|
store
|
||||||
|
.put(&answer.id, serde_json::to_vec(&answer)?.into())
|
||||||
|
.await?;
|
||||||
let parent_hashes = vec![answer.id.clone()];
|
let parent_hashes = vec![answer.id.clone()];
|
||||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||||
let event = crate::events::AnswerSubmitted {
|
let event = crate::events::AnswerSubmitted {
|
||||||
@@ -236,15 +312,29 @@ async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream:
|
|||||||
/// `invited`; a removed one is invited again; an invited or active one
|
/// `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
|
/// is left as it is, so a second grant costs nothing. `created` in
|
||||||
/// what comes back says whether the person had a place already.
|
/// 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> {
|
pub async fn grant(
|
||||||
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
|
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?;
|
let store = store(state).await?;
|
||||||
// The person's record in this group, however it was keyed: a desk
|
// The person's record in this group, however it was keyed: a desk
|
||||||
// that invited by address and a decision that granted by phone
|
// that invited by address and a decision that granted by phone
|
||||||
// number are talking about one person once a sign-in has joined
|
// number are talking about one person once a sign-in has joined
|
||||||
// the two, and must not make two.
|
// 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 existing: Option<Answer> = all(state)
|
||||||
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
|
.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 now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let invited = |created: bool| crate::people::Invited {
|
let invited = |created: bool| crate::people::Invited {
|
||||||
username: key.clone(),
|
username: key.clone(),
|
||||||
@@ -269,7 +359,18 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
|||||||
"name": name, "group": group, "by": by,
|
"name": name, "group": group, "by": by,
|
||||||
"phone": who.phone, "email": who.email, "sub": who.sub,
|
"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?;
|
crate::answers::store_answer(
|
||||||
|
&state.jetstream,
|
||||||
|
&state.aggregates.load(),
|
||||||
|
BUCKET,
|
||||||
|
id,
|
||||||
|
"/",
|
||||||
|
"Membership",
|
||||||
|
&responses,
|
||||||
|
now_ms,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(invited(true))
|
Ok(invited(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,11 +378,22 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
|||||||
|
|
||||||
/// Takes a person out of a group: the record moves to `removed` and
|
/// 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.
|
/// 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> {
|
pub async fn revoke(
|
||||||
let Some(key) = who.key() else { return Ok(false) };
|
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 store = store(state).await?;
|
||||||
let id = record_id(group, &key);
|
let id = record_id(group, &key);
|
||||||
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
|
let Some(answer) = store
|
||||||
|
.get(&id)
|
||||||
|
.await?
|
||||||
|
.and_then(|b| serde_json::from_slice::<Answer>(&b).ok())
|
||||||
|
else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if answer.state == REMOVED {
|
if answer.state == REMOVED {
|
||||||
@@ -296,10 +408,15 @@ pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity
|
|||||||
/// and their active groups come back to be their groups for the
|
/// and their active groups come back to be their groups for the
|
||||||
/// session. Their `sub` is remembered, so the next login matches on
|
/// session. Their `sub` is remembered, so the next login matches on
|
||||||
/// it before anything else.
|
/// it before anything else.
|
||||||
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
|
pub async fn on_signed_in(
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
user: &crate::auth::User,
|
||||||
|
) -> Vec<String> {
|
||||||
let who = Identity::from_user(user);
|
let who = Identity::from_user(user);
|
||||||
let records = all(state).await;
|
let records = all(state).await;
|
||||||
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
|
let Ok(store) = store(state).await else {
|
||||||
|
return groups_of(&records, &who);
|
||||||
|
};
|
||||||
let mut settled = Vec::with_capacity(records.len());
|
let mut settled = Vec::with_capacity(records.len());
|
||||||
for answer in records {
|
for answer in records {
|
||||||
let held = identity_of(&answer);
|
let held = identity_of(&answer);
|
||||||
@@ -341,7 +458,13 @@ pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::U
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
|
fn record(
|
||||||
|
group: &str,
|
||||||
|
state: &str,
|
||||||
|
phone: Option<&str>,
|
||||||
|
email: Option<&str>,
|
||||||
|
sub: Option<&str>,
|
||||||
|
) -> Answer {
|
||||||
Answer {
|
Answer {
|
||||||
id: record_id(group, "k"),
|
id: record_id(group, "k"),
|
||||||
question_id: "/".into(),
|
question_id: "/".into(),
|
||||||
@@ -353,44 +476,95 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_phone_number_is_one_string_for_everyone() {
|
fn a_phone_number_is_one_string_for_everyone() {
|
||||||
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
|
assert_eq!(
|
||||||
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
|
normalize_phone("912 34 567", "+47"),
|
||||||
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
|
Some("+4791234567".into())
|
||||||
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".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("kari@vel.no", "+47"), None);
|
||||||
assert_eq!(normalize_phone("123", "+47"), None);
|
assert_eq!(normalize_phone("123", "+47"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_person_is_their_phone_first_and_their_address_second() {
|
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" }));
|
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.key().as_deref(), Some("_4791234567"));
|
||||||
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
||||||
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "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!(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!(
|
||||||
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
|
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());
|
assert!(Identity::default().key().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
|
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()) };
|
let kari = Identity {
|
||||||
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
|
sub: Some("abc".into()),
|
||||||
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
|
phone: Some("+4791234567".into()),
|
||||||
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
|
email: Some("kari@vel.no".into()),
|
||||||
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
|
};
|
||||||
|
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));
|
assert!(!Identity::default().matches(&kari));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_active_memberships_are_groups() {
|
fn only_active_memberships_are_groups() {
|
||||||
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
let kari = Identity {
|
||||||
|
sub: None,
|
||||||
|
phone: Some("+4791234567".into()),
|
||||||
|
email: Some("kari@vel.no".into()),
|
||||||
|
};
|
||||||
let records = vec![
|
let records = vec![
|
||||||
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
||||||
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
||||||
|
|||||||
+332
-68
@@ -37,14 +37,25 @@ pub enum People {
|
|||||||
impl People {
|
impl People {
|
||||||
/// `None` only when Kanidm is asked for and not configured.
|
/// `None` only when Kanidm is asked for and not configured.
|
||||||
pub fn from_env() -> Option<Self> {
|
pub fn from_env() -> Option<Self> {
|
||||||
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
|
match std::env::var("PEOPLE_BACKEND")
|
||||||
|
.ok()
|
||||||
|
.map(|b| b.trim().to_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
Some("memberships") => Some(People::Memberships),
|
Some("memberships") => Some(People::Memberships),
|
||||||
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
|
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
|
||||||
Some(other) => {
|
Some(other) => {
|
||||||
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
|
tracing::warn!(
|
||||||
|
backend = other,
|
||||||
|
"PEOPLE_BACKEND is neither kanidm nor memberships; using memberships"
|
||||||
|
);
|
||||||
Some(People::Memberships)
|
Some(People::Memberships)
|
||||||
}
|
}
|
||||||
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
|
None => Some(
|
||||||
|
Kanidm::from_env()
|
||||||
|
.map(People::Kanidm)
|
||||||
|
.unwrap_or(People::Memberships),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +66,19 @@ impl People {
|
|||||||
/// through Kanidm an account and a one-time link, through
|
/// through Kanidm an account and a one-time link, through
|
||||||
/// memberships a record that becomes theirs when they sign in.
|
/// memberships a record that becomes theirs when they sign in.
|
||||||
/// `by` is who asked, for the record.
|
/// `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> {
|
pub async fn invite(
|
||||||
|
&self,
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
name: &str,
|
||||||
|
who: &crate::memberships::Identity,
|
||||||
|
group: &str,
|
||||||
|
by: &str,
|
||||||
|
) -> anyhow::Result<Invited> {
|
||||||
match self {
|
match self {
|
||||||
People::Kanidm(kanidm) => {
|
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"))?;
|
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
|
kanidm.invite(name, email, group).await
|
||||||
}
|
}
|
||||||
People::Memberships => {
|
People::Memberships => {
|
||||||
@@ -75,7 +95,9 @@ impl People {
|
|||||||
invited.link_expires_ms = account.link_expires_ms;
|
invited.link_expires_ms = account.link_expires_ms;
|
||||||
invited.created = account.created;
|
invited.created = account.created;
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
|
Err(e) => {
|
||||||
|
tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(invited)
|
Ok(invited)
|
||||||
@@ -85,10 +107,19 @@ impl People {
|
|||||||
|
|
||||||
/// Takes a person out of a group. Through Kanidm only what portal
|
/// Takes a person out of a group. Through Kanidm only what portal
|
||||||
/// put there (see `Invited.already_member`); memberships remember.
|
/// 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<()> {
|
pub async fn remove(
|
||||||
|
&self,
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
who: &crate::memberships::Identity,
|
||||||
|
username: &str,
|
||||||
|
group: &str,
|
||||||
|
by: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
match self {
|
match self {
|
||||||
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
|
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
|
||||||
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
|
People::Memberships => crate::memberships::revoke(state, group, who, by)
|
||||||
|
.await
|
||||||
|
.map(|_| ()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +171,9 @@ pub struct Invited {
|
|||||||
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
|
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
|
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()),
|
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
|
||||||
|
.ok()
|
||||||
|
.map(|t| t.timestamp_millis()),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +196,11 @@ fn members_from(body: &str) -> Vec<String> {
|
|||||||
fn has_credentials_from(body: &str) -> bool {
|
fn has_credentials_from(body: &str) -> bool {
|
||||||
serde_json::from_str::<serde_json::Value>(body)
|
serde_json::from_str::<serde_json::Value>(body)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty()))
|
.and_then(|v| {
|
||||||
|
v.get("creds")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.map(|c| !c.is_empty())
|
||||||
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +221,15 @@ fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
|
|||||||
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
|
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
|
||||||
fn name_from_search(body: &str) -> Option<String> {
|
fn name_from_search(body: &str) -> Option<String> {
|
||||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
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)
|
v.get("entries")?
|
||||||
|
.as_array()?
|
||||||
|
.first()?
|
||||||
|
.get("attrs")?
|
||||||
|
.get("name")?
|
||||||
|
.as_array()?
|
||||||
|
.first()?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Kanidm {
|
pub struct Kanidm {
|
||||||
@@ -202,13 +247,30 @@ impl Kanidm {
|
|||||||
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
|
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
|
||||||
/// a clear message instead of a half-made account.
|
/// a clear message instead of a half-made account.
|
||||||
pub fn from_env() -> Option<Self> {
|
pub fn from_env() -> Option<Self> {
|
||||||
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string();
|
let base = std::env::var("KANIDM_URL")
|
||||||
let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
|
.ok()?
|
||||||
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string();
|
.trim_end_matches('/')
|
||||||
Some(Kanidm { base, token, group_prefix })
|
.to_string();
|
||||||
|
let token = std::env::var("KANIDM_API_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|t| !t.is_empty())?;
|
||||||
|
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
Some(Kanidm {
|
||||||
|
base,
|
||||||
|
token,
|
||||||
|
group_prefix,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
method: openidconnect::reqwest::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> anyhow::Result<(u16, String)> {
|
||||||
let mut req = crate::http::client()
|
let mut req = crate::http::client()
|
||||||
.request(method, format!("{}{path}", self.base))
|
.request(method, format!("{}{path}", self.base))
|
||||||
.bearer_auth(&self.token)
|
.bearer_auth(&self.token)
|
||||||
@@ -223,8 +285,15 @@ impl Kanidm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
let (status, text) = self
|
||||||
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
.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
|
/// The account that already carries this email, if the token may
|
||||||
@@ -275,7 +344,13 @@ impl Kanidm {
|
|||||||
|
|
||||||
/// Who is in a group, by account name.
|
/// Who is in a group, by account name.
|
||||||
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> {
|
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", self.group_prefix), None).await?;
|
let (status, text) = self
|
||||||
|
.call(
|
||||||
|
openidconnect::reqwest::Method::GET,
|
||||||
|
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
|
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
|
||||||
}
|
}
|
||||||
@@ -286,7 +361,11 @@ impl Kanidm {
|
|||||||
/// service account manages, like adding them was.
|
/// service account manages, like adding them was.
|
||||||
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
|
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
|
||||||
let (status, text) = self
|
let (status, text) = self
|
||||||
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
|
.call(
|
||||||
|
openidconnect::reqwest::Method::DELETE,
|
||||||
|
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
|
||||||
|
Some(serde_json::json!([username])),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
|
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
|
||||||
@@ -297,7 +376,13 @@ impl Kanidm {
|
|||||||
/// Whether Kanidm shows a credential for this person. `false` is
|
/// Whether Kanidm shows a credential for this person. `false` is
|
||||||
/// "not that it can see", never "none": see `has_credentials_from`.
|
/// "not that it can see", never "none": see `has_credentials_from`.
|
||||||
pub async fn has_credentials(&self, username: &str) -> anyhow::Result<bool> {
|
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?;
|
let (status, text) = self
|
||||||
|
.call(
|
||||||
|
openidconnect::reqwest::Method::GET,
|
||||||
|
&format!("/v1/person/{username}/_credential/_status"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
anyhow::bail!("kanidm: credential status of {username}: http {status}");
|
anyhow::bail!("kanidm: credential status of {username}: http {status}");
|
||||||
}
|
}
|
||||||
@@ -308,7 +393,11 @@ impl Kanidm {
|
|||||||
/// passkey, and when it runs out.
|
/// passkey, and when it runs out.
|
||||||
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
|
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
|
||||||
let (status, text) = self
|
let (status, text) = self
|
||||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None)
|
.call(
|
||||||
|
openidconnect::reqwest::Method::GET,
|
||||||
|
&format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||||
@@ -320,10 +409,18 @@ impl Kanidm {
|
|||||||
expiry_time: Option<serde_json::Value>,
|
expiry_time: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
let intent: Intent = serde_json::from_str(&text)?;
|
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)))
|
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> {
|
async fn onboard(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
email: &str,
|
||||||
|
group: Option<&str>,
|
||||||
|
) -> anyhow::Result<Invited> {
|
||||||
let existing = self.find_by_mail(email).await?;
|
let existing = self.find_by_mail(email).await?;
|
||||||
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||||
let created = if existing.is_some() || self.person_exists(&username).await? {
|
let created = if existing.is_some() || self.person_exists(&username).await? {
|
||||||
@@ -351,9 +448,17 @@ impl Kanidm {
|
|||||||
// were here before is what says whose the membership is; a
|
// were here before is what says whose the membership is; a
|
||||||
// failed read says "not before", which errs toward portal
|
// failed read says "not before", which errs toward portal
|
||||||
// tidying up after itself.
|
// tidying up after itself.
|
||||||
already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
|
already_member = !created
|
||||||
|
&& self
|
||||||
|
.members(group)
|
||||||
|
.await
|
||||||
|
.is_ok_and(|m| m.contains(&username));
|
||||||
let (status, text) = self
|
let (status, text) = self
|
||||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
|
.call(
|
||||||
|
openidconnect::reqwest::Method::POST,
|
||||||
|
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
|
||||||
|
Some(serde_json::json!([username])),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
||||||
@@ -393,7 +498,10 @@ fn link_runs_out(answer: &crate::answers::Answer) -> i64 {
|
|||||||
|
|
||||||
/// The account an invite record is about.
|
/// The account an invite record is about.
|
||||||
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
||||||
answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok())
|
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
|
/// Where a waiting invite goes now, if anywhere. Pure: what is known
|
||||||
@@ -401,7 +509,11 @@ fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
|||||||
///
|
///
|
||||||
/// An invite that made no account has no link to wait for: the person
|
/// 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.
|
/// 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> {
|
pub fn invite_due(
|
||||||
|
answer: &crate::answers::Answer,
|
||||||
|
is_in: bool,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Option<&'static str> {
|
||||||
if answer.state != crate::answers::OPEN_STATE {
|
if answer.state != crate::answers::OPEN_STATE {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -418,7 +530,11 @@ pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) ->
|
|||||||
/// An invite nobody at a desk made - the first person, someone the
|
/// An invite nobody at a desk made - the first person, someone the
|
||||||
/// content names as responsible - on file like any other, so the desk
|
/// content names as responsible - on file like any other, so the desk
|
||||||
/// that reads `portal_invites` sees it, and can send it again.
|
/// that reads `portal_invites` sees it, and can send it again.
|
||||||
pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) {
|
pub async fn record(
|
||||||
|
state: &crate::server::AppState,
|
||||||
|
invited: &Invited,
|
||||||
|
responsible_for: &[String],
|
||||||
|
) {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let mut responses = serde_json::json!({
|
let mut responses = serde_json::json!({
|
||||||
"name": invited.display_name,
|
"name": invited.display_name,
|
||||||
@@ -429,23 +545,53 @@ pub async fn record(state: &crate::server::AppState, invited: &Invited, responsi
|
|||||||
responses["responsible_for"] = serde_json::json!(responsible_for);
|
responses["responsible_for"] = serde_json::json!(responsible_for);
|
||||||
}
|
}
|
||||||
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
|
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 {
|
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}");
|
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
|
/// Moves an invite record, the way a desk button would: through the
|
||||||
/// event log, onto the projection, and out on the answers subject.
|
/// 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) {
|
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 aggregates = state.aggregates.load();
|
||||||
let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else {
|
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
|
// A content repo may declare the bucket itself, without this
|
||||||
// state. Then the invite stays where its own graph has it.
|
// state. Then the invite stays where its own graph has it.
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY });
|
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 {
|
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}");
|
tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -454,7 +600,11 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
|
|||||||
answer.decided_by = Some(BY.to_string());
|
answer.decided_by = Some(BY.to_string());
|
||||||
// The link is a way in for as long as it is on file; one that is
|
// 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.
|
// 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()) {
|
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);
|
invited.insert("reset_link".into(), serde_json::Value::Null);
|
||||||
}
|
}
|
||||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||||
@@ -478,17 +628,33 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every invite on file that is still waiting in one of `states`.
|
/// 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>)> {
|
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.
|
// 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 store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?;
|
||||||
let keys: Vec<String> = {
|
let keys: Vec<String> = {
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
store.keys().await.ok()?.try_collect().await.unwrap_or_default()
|
store
|
||||||
|
.keys()
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.try_collect()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
let mut found = Vec::new();
|
let mut found = Vec::new();
|
||||||
for id in keys {
|
for id in keys {
|
||||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
let Ok(Some(bytes)) = store.get(&id).await else {
|
||||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if states.contains(&answer.state.as_str()) {
|
if states.contains(&answer.state.as_str()) {
|
||||||
found.push(answer);
|
found.push(answer);
|
||||||
}
|
}
|
||||||
@@ -509,12 +675,17 @@ pub async fn run(state: crate::server::AppState) {
|
|||||||
/// One pass over the waiting invites: done for whoever Kanidm shows a
|
/// One pass over the waiting invites: done for whoever Kanidm shows a
|
||||||
/// credential for, expired for a link that ran out.
|
/// credential for, expired for a link that ran out.
|
||||||
pub async fn sweep_invites(state: &crate::server::AppState) {
|
pub async fn sweep_invites(state: &crate::server::AppState) {
|
||||||
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return };
|
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let kanidm = Kanidm::from_env();
|
let kanidm = Kanidm::from_env();
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
for answer in waiting {
|
for answer in waiting {
|
||||||
let is_in = match (&kanidm, invited_of(&answer)) {
|
let is_in = match (&kanidm, invited_of(&answer)) {
|
||||||
(Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false),
|
(Some(kanidm), Some(invited)) if invited.created => kanidm
|
||||||
|
.has_credentials(&invited.username)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if let Some(target) = invite_due(&answer, is_in, now_ms) {
|
if let Some(target) = invite_due(&answer, is_in, now_ms) {
|
||||||
@@ -527,14 +698,23 @@ pub async fn sweep_invites(state: &crate::server::AppState) {
|
|||||||
/// address it went to for someone whose account goes by another name.
|
/// address it went to for someone whose account goes by another name.
|
||||||
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
|
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
|
||||||
let address = invited.email.trim().to_lowercase();
|
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))
|
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
|
/// 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 -
|
/// run out, are done. This is the one sure sign a link was used -
|
||||||
/// they are here.
|
/// they are here.
|
||||||
pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) {
|
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 };
|
let Some((store, waiting)) =
|
||||||
|
invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
for answer in waiting {
|
for answer in waiting {
|
||||||
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
|
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
|
||||||
move_invite(&state, &store, answer, INVITE_DONE).await;
|
move_invite(&state, &store, answer, INVITE_DONE).await;
|
||||||
@@ -547,11 +727,20 @@ pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::Use
|
|||||||
/// time, which for someone the content names as responsible is the one
|
/// time, which for someone the content names as responsible is the one
|
||||||
/// that says so.
|
/// that says so.
|
||||||
pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
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(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else {
|
||||||
let Ok(Some(bytes)) = store.get(id).await else { return };
|
return;
|
||||||
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) 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 {
|
let Some(mut invited) = invited_of(&answer) else {
|
||||||
tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent");
|
tracing::warn!(
|
||||||
|
id,
|
||||||
|
"an invite was sent again, but the record names no account - nothing sent"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(kanidm) = Kanidm::from_env() else {
|
let Some(kanidm) = Kanidm::from_env() else {
|
||||||
@@ -568,7 +757,10 @@ pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
|||||||
invited.reset_link = Some(link);
|
invited.reset_link = Some(link);
|
||||||
invited.link_expires_ms = expires;
|
invited.link_expires_ms = expires;
|
||||||
if let Some(map) = answer.responses.as_object_mut() {
|
if let Some(map) = answer.responses.as_object_mut() {
|
||||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
map.insert(
|
||||||
|
"invited".to_string(),
|
||||||
|
serde_json::to_value(&invited).unwrap_or_default(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||||
let _ = store.put(id, data.into()).await;
|
let _ = store.put(id, data.into()).await;
|
||||||
@@ -592,7 +784,13 @@ pub fn username_for(email: &str) -> String {
|
|||||||
let local = email.split('@').next().unwrap_or(email).to_lowercase();
|
let local = email.split('@').next().unwrap_or(email).to_lowercase();
|
||||||
let mut out: String = local
|
let mut out: String = local
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
|
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
|
||||||
out.remove(0);
|
out.remove(0);
|
||||||
@@ -645,17 +843,33 @@ pub async fn on_state(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(people) = People::from_env() else {
|
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);
|
tracing::warn!(
|
||||||
|
bucket,
|
||||||
|
id,
|
||||||
|
to_state,
|
||||||
|
group,
|
||||||
|
"this state grants a group, but {} - nobody was added",
|
||||||
|
People::NOT_CONFIGURED
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some((name, who)) = who_for(responses) else {
|
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");
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await {
|
let invited = match people
|
||||||
|
.invite(state, &name, &who, &group, &format!("{bucket}:{id}"))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(invited) => invited,
|
Ok(invited) => invited,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
|
tracing::error!(
|
||||||
|
bucket,
|
||||||
|
id,
|
||||||
|
to_state,
|
||||||
|
group,
|
||||||
|
"granting the group failed: {e}"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -673,14 +887,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn usernames_are_the_email_local_part_made_safe() {
|
fn usernames_are_the_email_local_part_made_safe() {
|
||||||
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
|
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
|
||||||
assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site");
|
assert_eq!(
|
||||||
|
username_for("Ole.Nordmann+site@Example.com"),
|
||||||
|
"ole.nordmann-site"
|
||||||
|
);
|
||||||
assert_eq!(username_for("42@x.no"), "person");
|
assert_eq!(username_for("42@x.no"), "person");
|
||||||
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_search_by_mail_yields_the_accounts_own_name() {
|
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":[{"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(r#"{"entries":[]}"#), None);
|
||||||
assert_eq!(name_from_search("null"), None);
|
assert_eq!(name_from_search("null"), None);
|
||||||
assert_eq!(name_from_search("not json"), None);
|
assert_eq!(name_from_search("not json"), None);
|
||||||
@@ -688,21 +908,32 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_link_runs_out_when_kanidm_says_in_either_spelling() {
|
fn a_link_runs_out_when_kanidm_says_in_either_spelling() {
|
||||||
assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000));
|
assert_eq!(
|
||||||
assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000));
|
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);
|
assert_eq!(expiry_ms(&serde_json::json!(null)), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn members_are_account_names_and_an_empty_group_is_null() {
|
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_eq!(
|
||||||
|
members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#),
|
||||||
|
vec!["kari", "ola"]
|
||||||
|
);
|
||||||
assert!(members_from("null").is_empty());
|
assert!(members_from("null").is_empty());
|
||||||
assert!(members_from("nonsense").is_empty());
|
assert!(members_from("nonsense").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_a_listed_credential_counts() {
|
fn only_a_listed_credential_counts() {
|
||||||
assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#));
|
assert!(has_credentials_from(
|
||||||
|
r#"{"creds":[{"type_":"PasswordMfa"}]}"#
|
||||||
|
));
|
||||||
assert!(!has_credentials_from(r#"{"creds":[]}"#));
|
assert!(!has_credentials_from(r#"{"creds":[]}"#));
|
||||||
assert!(!has_credentials_from("null"));
|
assert!(!has_credentials_from("null"));
|
||||||
}
|
}
|
||||||
@@ -722,6 +953,7 @@ mod tests {
|
|||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,24 +961,53 @@ mod tests {
|
|||||||
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
|
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
|
||||||
let day = LINK_SECONDS * 1000;
|
let day = LINK_SECONDS * 1000;
|
||||||
// Still within the link's life, and nobody has seen them: wait.
|
// 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!(
|
||||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED));
|
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.
|
// 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!(
|
||||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE));
|
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.
|
// 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!(
|
||||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED));
|
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.
|
// 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));
|
assert_eq!(
|
||||||
|
invite_due(&invite("open", false, None), false, 10),
|
||||||
|
Some(INVITE_DONE)
|
||||||
|
);
|
||||||
// Only a waiting invite moves.
|
// Only a waiting invite moves.
|
||||||
assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None);
|
assert_eq!(
|
||||||
assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None);
|
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]
|
#[test]
|
||||||
fn an_invite_is_theirs_by_account_or_by_address() {
|
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 invited: Invited =
|
||||||
|
serde_json::from_value(invite("open", true, None).responses["invited"].clone())
|
||||||
|
.unwrap();
|
||||||
let user = |username: &str, email: Option<&str>| crate::auth::User {
|
let user = |username: &str, email: Option<&str>| crate::auth::User {
|
||||||
sub: "s".into(),
|
sub: "s".into(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
@@ -765,7 +1026,10 @@ mod tests {
|
|||||||
fn a_null_person_answer_means_nobody_there() {
|
fn a_null_person_answer_means_nobody_there() {
|
||||||
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||||
assert_eq!(person_lookup_found(200, "null\n"), 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(200, r#"{"attrs":{"name":["bl"]}}"#),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
assert_eq!(person_lookup_found(404, ""), Some(false));
|
assert_eq!(person_lookup_found(404, ""), Some(false));
|
||||||
assert_eq!(person_lookup_found(401, "unauthorized"), None);
|
assert_eq!(person_lookup_found(401, "unauthorized"), None);
|
||||||
}
|
}
|
||||||
|
|||||||
+443
@@ -0,0 +1,443 @@
|
|||||||
|
//! An Apple configuration profile, rendered from the mail server's own
|
||||||
|
//! PACC document.
|
||||||
|
//!
|
||||||
|
//! iOS has no autoconfiguration for IMAP accounts: Apple Mail reads neither
|
||||||
|
//! Mozilla autoconfig nor Microsoft Autodiscover (those are for ActiveSync),
|
||||||
|
//! so adding an account by hand means the device probing for ports. A probe
|
||||||
|
//! that meets a *filtered* port - a firewall that drops rather than refuses -
|
||||||
|
//! waits out a TCP timeout and the account is reported as unverifiable, which
|
||||||
|
//! reads to the person as "wrong password". A profile ends the guessing: it
|
||||||
|
//! states the hosts and the ports, and carries mail, calendars and contacts in
|
||||||
|
//! one install.
|
||||||
|
//!
|
||||||
|
//! What it does not do is invent any of those values. `site.yaml` names a
|
||||||
|
//! **PACC** document (`/.well-known/user-agent-configuration.json`, which
|
||||||
|
//! Stalwart generates and which is defined to live on the mail domain), and
|
||||||
|
//! every host, port and collection path in the profile comes from there. So
|
||||||
|
//! the ports are never written down in two places, and a server that moves a
|
||||||
|
//! collection moves it in the profile too.
|
||||||
|
//!
|
||||||
|
//! The profile asks for the address and the password at install time rather
|
||||||
|
//! than embedding them: it holds nothing personal, so one document serves
|
||||||
|
//! everyone with an account and it can be fetched by anyone - it says no more
|
||||||
|
//! than the PACC document it is built from already says in public.
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use crate::content::ProfileConfig;
|
||||||
|
|
||||||
|
/// The parts of a PACC document this renders from. Unknown protocols and
|
||||||
|
/// members are ignored: PACC grows, and a profile only needs what iOS can
|
||||||
|
/// be told about.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
struct Pacc {
|
||||||
|
#[serde(default)]
|
||||||
|
protocols: Protocols,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
struct Protocols {
|
||||||
|
#[serde(default)]
|
||||||
|
imap: Option<HostPort>,
|
||||||
|
#[serde(default)]
|
||||||
|
smtp: Option<HostPort>,
|
||||||
|
#[serde(default)]
|
||||||
|
caldav: Option<Endpoint>,
|
||||||
|
#[serde(default)]
|
||||||
|
carddav: Option<Endpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A protocol spoken on a host. PACC may state the port; when it does not,
|
||||||
|
/// the caller supplies the registered implicit-TLS default.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct HostPort {
|
||||||
|
host: String,
|
||||||
|
#[serde(default)]
|
||||||
|
port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Endpoint {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host, port and path of a DAV collection URL. A collection whose URL
|
||||||
|
/// cannot be parsed is left out of the profile rather than guessed at.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn split_url(url: &str) -> Option<(String, u16, String)> {
|
||||||
|
let rest = url.strip_prefix("https://")?;
|
||||||
|
let (authority, path) = match rest.find('/') {
|
||||||
|
Some(i) => (&rest[..i], &rest[i..]),
|
||||||
|
None => (rest, "/"),
|
||||||
|
};
|
||||||
|
let (host, port) = match authority.rsplit_once(':') {
|
||||||
|
Some((h, p)) => (h.to_string(), p.parse().ok()?),
|
||||||
|
None => (authority.to_string(), 443),
|
||||||
|
};
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((host, port, path.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A UUID derived from what the payload is for, not drawn at random: iOS
|
||||||
|
/// replaces an installed profile whose identifier and UUID it already has, and
|
||||||
|
/// stacks a duplicate when they differ. Rendering the document twice has to
|
||||||
|
/// produce the same ids, so reinstalling updates the account instead of adding
|
||||||
|
/// a second one beside it.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn stable_uuid(seed: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let h = Sha256::digest(seed.as_bytes());
|
||||||
|
let b: Vec<String> = h.iter().take(16).map(|x| format!("{x:02X}")).collect();
|
||||||
|
let s = b.join("");
|
||||||
|
format!(
|
||||||
|
"{}-{}-{}-{}-{}",
|
||||||
|
&s[0..8],
|
||||||
|
&s[8..12],
|
||||||
|
&s[12..16],
|
||||||
|
&s[16..20],
|
||||||
|
&s[20..32]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn esc(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn string(key: &str, value: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"\t\t\t<key>{key}</key>\n\t\t\t<string>{}</string>\n",
|
||||||
|
esc(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn integer(key: &str, value: u16) -> String {
|
||||||
|
format!("\t\t\t<key>{key}</key>\n\t\t\t<integer>{value}</integer>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn boolean(key: &str, value: bool) -> String {
|
||||||
|
format!(
|
||||||
|
"\t\t\t<key>{key}</key>\n\t\t\t<{}/>\n",
|
||||||
|
if value { "true" } else { "false" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the profile. `domain` is the site's own host, which seeds the
|
||||||
|
/// identifiers and names the accounts on the device.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub fn render(pacc: &str, domain: &str, cfg: &ProfileConfig) -> anyhow::Result<String> {
|
||||||
|
let pacc: Pacc = serde_json::from_str(pacc)?;
|
||||||
|
let name = cfg.name.clone().unwrap_or_else(|| domain.to_string());
|
||||||
|
let base = format!("{domain}.profile");
|
||||||
|
let mut payloads = String::new();
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Mail. Both halves have to be there: a profile with an incoming server
|
||||||
|
// and no outgoing one installs an account that cannot send.
|
||||||
|
if let (Some(imap), Some(smtp)) = (&pacc.protocols.imap, &pacc.protocols.smtp) {
|
||||||
|
count += 1;
|
||||||
|
payloads.push_str("\t\t<dict>\n");
|
||||||
|
payloads.push_str(&string("PayloadType", "com.apple.mail.managed"));
|
||||||
|
payloads.push_str(&integer("PayloadVersion", 1));
|
||||||
|
payloads.push_str(&string("PayloadIdentifier", &format!("{base}.mail")));
|
||||||
|
payloads.push_str(&string(
|
||||||
|
"PayloadUUID",
|
||||||
|
&stable_uuid(&format!("{base}.mail")),
|
||||||
|
));
|
||||||
|
payloads.push_str(&string("PayloadDisplayName", &format!("{name} mail")));
|
||||||
|
payloads.push_str(&string("EmailAccountType", "EmailTypeIMAP"));
|
||||||
|
payloads.push_str(&string("EmailAccountDescription", &name));
|
||||||
|
payloads.push_str(&string("IncomingMailServerHostName", &imap.host));
|
||||||
|
payloads.push_str(&integer(
|
||||||
|
"IncomingMailServerPortNumber",
|
||||||
|
imap.port.unwrap_or(993),
|
||||||
|
));
|
||||||
|
payloads.push_str(&boolean("IncomingMailServerUseSSL", true));
|
||||||
|
payloads.push_str(&string(
|
||||||
|
"IncomingMailServerAuthentication",
|
||||||
|
"EmailAuthPassword",
|
||||||
|
));
|
||||||
|
payloads.push_str(&string("OutgoingMailServerHostName", &smtp.host));
|
||||||
|
payloads.push_str(&integer(
|
||||||
|
"OutgoingMailServerPortNumber",
|
||||||
|
smtp.port.unwrap_or(465),
|
||||||
|
));
|
||||||
|
payloads.push_str(&boolean("OutgoingMailServerUseSSL", true));
|
||||||
|
payloads.push_str(&string(
|
||||||
|
"OutgoingMailServerAuthentication",
|
||||||
|
"EmailAuthPassword",
|
||||||
|
));
|
||||||
|
// The one password is the same both ways, so the person types it once.
|
||||||
|
payloads.push_str(&boolean("OutgoingPasswordSameAsIncomingPassword", true));
|
||||||
|
payloads.push_str("\t\t</dict>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (proto, endpoint, kind, label) in [
|
||||||
|
(
|
||||||
|
"caldav",
|
||||||
|
pacc.protocols.caldav.as_ref(),
|
||||||
|
"com.apple.caldav.account",
|
||||||
|
"calendars",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"carddav",
|
||||||
|
pacc.protocols.carddav.as_ref(),
|
||||||
|
"com.apple.carddav.account",
|
||||||
|
"contacts",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let Some((host, port, path)) = endpoint.and_then(|e| split_url(&e.url)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
count += 1;
|
||||||
|
let prefix = if proto == "caldav" {
|
||||||
|
"CalDAV"
|
||||||
|
} else {
|
||||||
|
"CardDAV"
|
||||||
|
};
|
||||||
|
payloads.push_str("\t\t<dict>\n");
|
||||||
|
payloads.push_str(&string("PayloadType", kind));
|
||||||
|
payloads.push_str(&integer("PayloadVersion", 1));
|
||||||
|
payloads.push_str(&string("PayloadIdentifier", &format!("{base}.{proto}")));
|
||||||
|
payloads.push_str(&string(
|
||||||
|
"PayloadUUID",
|
||||||
|
&stable_uuid(&format!("{base}.{proto}")),
|
||||||
|
));
|
||||||
|
payloads.push_str(&string("PayloadDisplayName", &format!("{name} {label}")));
|
||||||
|
payloads.push_str(&string(&format!("{prefix}AccountDescription"), &name));
|
||||||
|
payloads.push_str(&string(&format!("{prefix}HostName"), &host));
|
||||||
|
payloads.push_str(&integer(&format!("{prefix}Port"), port));
|
||||||
|
payloads.push_str(&boolean(&format!("{prefix}UseSSL"), true));
|
||||||
|
payloads.push_str(&string(&format!("{prefix}PrincipalURL"), &path));
|
||||||
|
payloads.push_str("\t\t</dict>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
anyhow::bail!("PACC document describes no protocol iOS can be configured with");
|
||||||
|
}
|
||||||
|
|
||||||
|
let organization = cfg.organization.clone().unwrap_or_else(|| name.clone());
|
||||||
|
Ok(format!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||||
|
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||||
|
<plist version=\"1.0\">\n\
|
||||||
|
<dict>\n\
|
||||||
|
\t<key>PayloadType</key>\n\t<string>Configuration</string>\n\
|
||||||
|
\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\
|
||||||
|
\t<key>PayloadIdentifier</key>\n\t<string>{base}</string>\n\
|
||||||
|
\t<key>PayloadUUID</key>\n\t<string>{uuid}</string>\n\
|
||||||
|
\t<key>PayloadDisplayName</key>\n\t<string>{display}</string>\n\
|
||||||
|
\t<key>PayloadOrganization</key>\n\t<string>{org}</string>\n\
|
||||||
|
\t<key>PayloadDescription</key>\n\t<string>{desc}</string>\n\
|
||||||
|
\t<key>PayloadRemovalDisallowed</key>\n\t<false/>\n\
|
||||||
|
\t<key>PayloadContent</key>\n\t<array>\n{payloads}\t</array>\n\
|
||||||
|
</dict>\n</plist>\n",
|
||||||
|
base = esc(&base),
|
||||||
|
uuid = stable_uuid(&base),
|
||||||
|
display = esc(&name),
|
||||||
|
org = esc(&organization),
|
||||||
|
desc = esc(&format!(
|
||||||
|
"Sets up {name} on this device. You will be asked for your address and password."
|
||||||
|
)),
|
||||||
|
payloads = payloads,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /profile.mobileconfig` - the profile for this site, or 404 when
|
||||||
|
/// `site.yaml` declares no `profile:`. Served as
|
||||||
|
/// `application/x-apple-aspen-config`, without which iOS downloads the file
|
||||||
|
/// instead of offering to install it.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn handler(
|
||||||
|
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
let site = state.site.load();
|
||||||
|
let Some(cfg) = site.profile.clone() else {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"this site offers no configuration profile",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
let doc = match crate::http::client()
|
||||||
|
.get(&cfg.pacc)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.and_then(|r| r.error_for_status())
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("profile: fetching {}: {e}", cfg.pacc);
|
||||||
|
return (StatusCode::BAD_GATEWAY, "cannot reach the mail server").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let body = match crate::http::bytes(doc, crate::http::ASSET_CAP).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
let text = match String::from_utf8(body) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return (StatusCode::BAD_GATEWAY, "PACC document is not UTF-8").into_response(),
|
||||||
|
};
|
||||||
|
let domain = crate::mail::public_url();
|
||||||
|
let domain = domain.trim_start_matches("https://").trim_end_matches('/');
|
||||||
|
match render(&text, domain, &cfg) {
|
||||||
|
Ok(plist) => (
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
|
||||||
|
(header::CACHE_CONTROL, "no-cache"),
|
||||||
|
],
|
||||||
|
plist,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("profile: {e}");
|
||||||
|
(StatusCode::BAD_GATEWAY, e.to_string()).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "ssr"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Stalwart 0.16's own PACC document, as served. Note what it does *not*
|
||||||
|
/// say: imap and smtp carry a host and no port, which is why the renderer
|
||||||
|
/// has to supply the registered implicit-TLS defaults.
|
||||||
|
const PACC: &str = r#"{"protocols":{"jmap":{"url":"https://mail.example.no/jmap/session"},
|
||||||
|
"imap":{"host":"mail.example.no"},"pop3":{"host":"mail.example.no"},
|
||||||
|
"smtp":{"host":"mail.example.no"},"caldav":{"url":"https://mail.example.no/dav/cal/"},
|
||||||
|
"carddav":{"url":"https://mail.example.no/dav/card/"},
|
||||||
|
"webdav":{"url":"https://mail.example.no/dav/file/"},
|
||||||
|
"managesieve":{"host":"mail.example.no"}},
|
||||||
|
"authentication":{"oauth-public":{"issuer":"https://mail.example.no"},"password":true},
|
||||||
|
"info":{"provider":{"name":"Stalwart"}}}"#;
|
||||||
|
|
||||||
|
fn cfg() -> ProfileConfig {
|
||||||
|
ProfileConfig {
|
||||||
|
pacc: "https://mail.example.no/.well-known/user-agent-configuration.json".into(),
|
||||||
|
name: Some("Example".into()),
|
||||||
|
organization: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_value_comes_from_the_pacc_document() {
|
||||||
|
let p = render(PACC, "example.no", &cfg()).unwrap();
|
||||||
|
assert!(p.contains("<string>com.apple.mail.managed</string>"));
|
||||||
|
// PayloadVersion is an integer everywhere, top level and in each
|
||||||
|
// payload: iOS reads the profile as a plist and the key is typed.
|
||||||
|
assert_eq!(
|
||||||
|
p.matches("<key>PayloadVersion</key>\n\t\t\t<integer>1</integer>")
|
||||||
|
.count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert!(!p.contains("<key>PayloadVersion</key>\n\t\t\t<string>"));
|
||||||
|
assert!(p.contains("<string>com.apple.caldav.account</string>"));
|
||||||
|
assert!(p.contains("<string>com.apple.carddav.account</string>"));
|
||||||
|
assert!(p.contains(
|
||||||
|
"<key>IncomingMailServerHostName</key>\n\t\t\t<string>mail.example.no</string>"
|
||||||
|
));
|
||||||
|
// The ports PACC left out, filled in from the registry, not invented.
|
||||||
|
assert!(p.contains("<key>IncomingMailServerPortNumber</key>\n\t\t\t<integer>993</integer>"));
|
||||||
|
assert!(p.contains("<key>OutgoingMailServerPortNumber</key>\n\t\t\t<integer>465</integer>"));
|
||||||
|
// A DAV URL becomes a host, a port and the collection's own path.
|
||||||
|
assert!(p.contains("<key>CalDAVHostName</key>\n\t\t\t<string>mail.example.no</string>"));
|
||||||
|
assert!(p.contains("<key>CalDAVPrincipalURL</key>\n\t\t\t<string>/dav/cal/</string>"));
|
||||||
|
assert!(p.contains("<key>CardDAVPrincipalURL</key>\n\t\t\t<string>/dav/card/</string>"));
|
||||||
|
assert!(p.contains("<key>CalDAVPort</key>\n\t\t\t<integer>443</integer>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing personal in the document: no address and no username, so iOS
|
||||||
|
/// asks at install and one profile serves everyone with an account.
|
||||||
|
#[test]
|
||||||
|
fn the_profile_holds_no_credentials() {
|
||||||
|
let p = render(PACC, "example.no", &cfg()).unwrap();
|
||||||
|
for key in [
|
||||||
|
"EmailAddress",
|
||||||
|
"IncomingMailServerUsername",
|
||||||
|
"OutgoingMailServerUsername",
|
||||||
|
"CalDAVUsername",
|
||||||
|
"CardDAVUsername",
|
||||||
|
"CalDAVPassword",
|
||||||
|
"Password",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!p.contains(&format!("<key>{key}</key>")),
|
||||||
|
"{key} must not be in the profile"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendering twice has to give the same ids, or a reinstall adds a second
|
||||||
|
/// account beside the first instead of replacing it.
|
||||||
|
#[test]
|
||||||
|
fn identifiers_are_stable_across_renders() {
|
||||||
|
let a = render(PACC, "example.no", &cfg()).unwrap();
|
||||||
|
let b = render(PACC, "example.no", &cfg()).unwrap();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
// ...and belong to this site alone.
|
||||||
|
let other = render(PACC, "other.no", &cfg()).unwrap();
|
||||||
|
assert_ne!(a, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PACC document that describes a port explicitly wins over the default.
|
||||||
|
#[test]
|
||||||
|
fn a_stated_port_is_used() {
|
||||||
|
let pacc = r#"{"protocols":{"imap":{"host":"m.example.no","port":1993},
|
||||||
|
"smtp":{"host":"m.example.no","port":1465}}}"#;
|
||||||
|
let p = render(pacc, "example.no", &cfg()).unwrap();
|
||||||
|
assert!(p.contains("<integer>1993</integer>"));
|
||||||
|
assert!(p.contains("<integer>1465</integer>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Half a mail account is worse than none: an account that cannot send is
|
||||||
|
/// not installed, and a document with nothing iOS understands is an error
|
||||||
|
/// rather than an empty profile that looks like it worked.
|
||||||
|
#[test]
|
||||||
|
fn an_incomplete_document_is_refused() {
|
||||||
|
let incoming_only = r#"{"protocols":{"imap":{"host":"m.example.no"}}}"#;
|
||||||
|
assert!(render(incoming_only, "example.no", &cfg()).is_err());
|
||||||
|
let jmap_only = r#"{"protocols":{"jmap":{"url":"https://m.example.no/jmap/session"}}}"#;
|
||||||
|
assert!(render(jmap_only, "example.no", &cfg()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name with an ampersand in it must not break the plist.
|
||||||
|
#[test]
|
||||||
|
fn names_are_escaped() {
|
||||||
|
let c = ProfileConfig {
|
||||||
|
name: Some("Smith & Co <mail>".into()),
|
||||||
|
..cfg()
|
||||||
|
};
|
||||||
|
let p = render(PACC, "example.no", &c).unwrap();
|
||||||
|
assert!(p.contains("Smith & Co <mail>"));
|
||||||
|
assert!(!p.contains("Smith & Co"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dav_url_that_cannot_be_parsed_is_left_out() {
|
||||||
|
assert_eq!(
|
||||||
|
split_url("https://m.example.no:8443/dav/cal/"),
|
||||||
|
Some(("m.example.no".into(), 8443, "/dav/cal/".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_url("https://m.example.no"),
|
||||||
|
Some(("m.example.no".into(), 443, "/".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(split_url("http://m.example.no/dav/cal/"), None);
|
||||||
|
assert_eq!(split_url("https://"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-6
@@ -23,14 +23,19 @@ pub struct Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn gather(state: &crate::server::AppState) -> Report {
|
pub async fn gather(state: &crate::server::AppState) -> Report {
|
||||||
let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() };
|
let mut report = Report {
|
||||||
|
storylines: state.questions.load().arity(),
|
||||||
|
buckets: BTreeMap::new(),
|
||||||
|
};
|
||||||
let aggregates = state.aggregates.load();
|
let aggregates = state.aggregates.load();
|
||||||
for bucket in aggregates.keys() {
|
for bucket in aggregates.keys() {
|
||||||
// The runtime's own buckets are not the site's offers.
|
// The runtime's own buckets are not the site's offers.
|
||||||
if bucket.starts_with("portal_") {
|
if bucket.starts_with("portal_") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(store) = state.jetstream.get_key_value(bucket).await else { continue };
|
let Ok(store) = state.jetstream.get_key_value(bucket).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let keys: Vec<String> = {
|
let keys: Vec<String> = {
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
match store.keys().await {
|
match store.keys().await {
|
||||||
@@ -40,14 +45,24 @@ pub async fn gather(state: &crate::server::AppState) -> Report {
|
|||||||
};
|
};
|
||||||
let counts = report.buckets.entry(bucket.clone()).or_default();
|
let counts = report.buckets.entry(bucket.clone()).or_default();
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
let Ok(Some(bytes)) = store.get(&key).await else {
|
||||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
continue;
|
||||||
*counts.entry(answer.storyline.to_string()).or_default().entry(answer.state).or_default() += 1;
|
};
|
||||||
|
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
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handler(axum::extract::State(state): axum::extract::State<crate::server::AppState>) -> axum::Json<Report> {
|
pub async fn handler(
|
||||||
|
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||||
|
) -> axum::Json<Report> {
|
||||||
axum::Json(gather(&state).await)
|
axum::Json(gather(&state).await)
|
||||||
}
|
}
|
||||||
|
|||||||
+310
-45
@@ -27,13 +27,18 @@ pub async fn get_resource(
|
|||||||
// `Resource::refetch()` after a fully-empty-params call failed with
|
// `Resource::refetch()` after a fully-empty-params call failed with
|
||||||
// "missing field `params`", even though the very first load (always
|
// "missing field `params`", even though the very first load (always
|
||||||
// server-resolved) never did.
|
// server-resolved) never did.
|
||||||
#[server(default)]
|
#[server(default)] params: std::collections::HashMap<String, String>,
|
||||||
params: std::collections::HashMap<String, String>,
|
|
||||||
) -> Result<serde_json::Value, ServerFnError> {
|
) -> Result<serde_json::Value, ServerFnError> {
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
let feature = find_feature(
|
||||||
|
&state,
|
||||||
|
crate::server::storyline::current().await,
|
||||||
|
&question_id,
|
||||||
|
&alternative,
|
||||||
|
&feature_name,
|
||||||
|
)?;
|
||||||
let resource = feature
|
let resource = feature
|
||||||
.resource
|
.resource
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -56,13 +61,18 @@ pub async fn get_requirement_options(
|
|||||||
feature_name: String,
|
feature_name: String,
|
||||||
requirement_name: String,
|
requirement_name: String,
|
||||||
// See get_resource's `params` for why this is needed.
|
// See get_resource's `params` for why this is needed.
|
||||||
#[server(default)]
|
#[server(default)] params: std::collections::HashMap<String, String>,
|
||||||
params: std::collections::HashMap<String, String>,
|
|
||||||
) -> Result<serde_json::Value, ServerFnError> {
|
) -> Result<serde_json::Value, ServerFnError> {
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
let feature = find_feature(
|
||||||
|
&state,
|
||||||
|
crate::server::storyline::current().await,
|
||||||
|
&question_id,
|
||||||
|
&alternative,
|
||||||
|
&feature_name,
|
||||||
|
)?;
|
||||||
let requirement = feature
|
let requirement = feature
|
||||||
.requirements
|
.requirements
|
||||||
.iter()
|
.iter()
|
||||||
@@ -82,7 +92,13 @@ pub async fn get_requirement_options(
|
|||||||
.resource
|
.resource
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
||||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
|
authorize_resource(
|
||||||
|
&state,
|
||||||
|
&question_id,
|
||||||
|
&alternative,
|
||||||
|
&format!("{feature_name}|{requirement_name}"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
|
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +113,18 @@ pub async fn get_requirement_binding(
|
|||||||
feature_name: String,
|
feature_name: String,
|
||||||
requirement_name: String,
|
requirement_name: String,
|
||||||
// See get_resource's `params` for why this is needed.
|
// See get_resource's `params` for why this is needed.
|
||||||
#[server(default)]
|
#[server(default)] params: std::collections::HashMap<String, String>,
|
||||||
params: std::collections::HashMap<String, String>,
|
|
||||||
) -> Result<serde_json::Value, ServerFnError> {
|
) -> Result<serde_json::Value, ServerFnError> {
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
let feature = find_feature(
|
||||||
|
&state,
|
||||||
|
crate::server::storyline::current().await,
|
||||||
|
&question_id,
|
||||||
|
&alternative,
|
||||||
|
&feature_name,
|
||||||
|
)?;
|
||||||
let requirement = feature
|
let requirement = feature
|
||||||
.requirements
|
.requirements
|
||||||
.iter()
|
.iter()
|
||||||
@@ -113,7 +134,13 @@ pub async fn get_requirement_binding(
|
|||||||
.bind
|
.bind
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
||||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
|
authorize_resource(
|
||||||
|
&state,
|
||||||
|
&question_id,
|
||||||
|
&alternative,
|
||||||
|
&format!("{feature_name}|{requirement_name}|bind"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
|
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +165,9 @@ fn find_feature(
|
|||||||
// also what substitutes the URL segment into the page's resource
|
// also what substitutes the URL segment into the page's resource
|
||||||
// keys - so this lookup is where a `/review/<record>` page's
|
// keys - so this lookup is where a `/review/<record>` page's
|
||||||
// feature acquires its record-specific key.
|
// feature acquires its record-specific key.
|
||||||
let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id)
|
let question =
|
||||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
crate::content::resolve_question(state.questions.load().at(storyline), question_id)
|
||||||
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||||
question
|
question
|
||||||
.alternatives
|
.alternatives
|
||||||
.iter()
|
.iter()
|
||||||
@@ -232,9 +260,16 @@ pub(crate) fn matches_filter(
|
|||||||
} else {
|
} else {
|
||||||
filter.equals.clone()
|
filter.equals.clone()
|
||||||
};
|
};
|
||||||
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
|
let wanted =
|
||||||
|
crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
|
||||||
// Addresses compare without case; everything else as written.
|
// Addresses compare without case; everything else as written.
|
||||||
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
|
let same = |found: &str| {
|
||||||
|
if filter.equals.contains("{user.email}") {
|
||||||
|
found.trim().eq_ignore_ascii_case(&wanted)
|
||||||
|
} else {
|
||||||
|
found == wanted
|
||||||
|
}
|
||||||
|
};
|
||||||
let found = value
|
let found = value
|
||||||
.get("responses")
|
.get("responses")
|
||||||
.and_then(|r| r.get(&filter.field))
|
.and_then(|r| r.get(&filter.field))
|
||||||
@@ -254,16 +289,24 @@ pub(crate) const USER_EMAIL: &str = "__user_email";
|
|||||||
/// The caller's parameters with the session's person put in and
|
/// The caller's parameters with the session's person put in and
|
||||||
/// anything pretending to be them taken out.
|
/// anything pretending to be them taken out.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
|
pub(crate) fn with_person(
|
||||||
|
mut params: std::collections::HashMap<String, String>,
|
||||||
|
user: Option<&crate::auth::User>,
|
||||||
|
) -> std::collections::HashMap<String, String> {
|
||||||
params.retain(|k, _| !k.starts_with("__"));
|
params.retain(|k, _| !k.starts_with("__"));
|
||||||
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
|
if let Some(email) = user
|
||||||
|
.and_then(|u| u.email.as_deref())
|
||||||
|
.filter(|e| !e.trim().is_empty())
|
||||||
|
{
|
||||||
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
|
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
|
||||||
}
|
}
|
||||||
params
|
params
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
|
async fn as_the_person_asking(
|
||||||
|
params: std::collections::HashMap<String, String>,
|
||||||
|
) -> std::collections::HashMap<String, String> {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
|
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
|
||||||
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
|
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
|
||||||
@@ -300,8 +343,8 @@ async fn fetch_resource_value(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
|
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
|
||||||
let value: serde_json::Value =
|
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
// A state filter holds for one record as well as a
|
// A state filter holds for one record as well as a
|
||||||
// listing, or naming the key would walk around it.
|
// listing, or naming the key would walk around it.
|
||||||
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
|
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
|
||||||
@@ -322,12 +365,19 @@ async fn fetch_resource_value(
|
|||||||
for key in keys {
|
for key in keys {
|
||||||
if let Ok(Some(bytes)) = store.get(&key).await {
|
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||||
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||||
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
|
if shows_state(resource, &value)
|
||||||
|
&& matches_filter(resource, &value, params)
|
||||||
|
{
|
||||||
items.push(value);
|
items.push(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !resource.transitions.is_empty() {
|
||||||
|
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
|
||||||
|
items = cap_finished(items, schema, finished_wanted(params));
|
||||||
|
}
|
||||||
|
}
|
||||||
serde_json::Value::Array(items)
|
serde_json::Value::Array(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,18 +386,89 @@ async fn fetch_resource_value(
|
|||||||
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
||||||
}
|
}
|
||||||
ResourceSource::GiteaReleases { owner, repo } => {
|
ResourceSource::GiteaReleases { owner, repo } => {
|
||||||
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
|
fetch_gitea_json(
|
||||||
.await?
|
state,
|
||||||
|
&format!("/api/v1/repos/{owner}/{repo}/releases"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
match &resource.jq {
|
let shaped = match &resource.jq {
|
||||||
Some(filter) => apply_jq(filter, &value),
|
Some(filter) => apply_jq(filter, &value)?,
|
||||||
None => Ok(value),
|
None => value,
|
||||||
|
};
|
||||||
|
Ok(apply_limit(resource.limit, shaped))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep at most `n` items, after `jq` has had its say.
|
||||||
|
///
|
||||||
|
/// Anything that is not a list passes through: a single record has no
|
||||||
|
/// length to cap, and silently turning one into an empty list would be
|
||||||
|
/// worse than ignoring a limit that cannot apply. A limit of zero does
|
||||||
|
/// mean nothing — the resource's `empty` line speaks instead, which is
|
||||||
|
/// a coherent thing for content to ask for.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn apply_limit(limit: Option<usize>, value: serde_json::Value) -> serde_json::Value {
|
||||||
|
let Some(n) = limit else { return value };
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(mut items) => {
|
||||||
|
items.truncate(n);
|
||||||
|
serde_json::Value::Array(items)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many finished records a desk listing should show: three, or
|
||||||
|
/// what the page asked for with `__finished` after "show older".
|
||||||
|
pub const FINISHED_PARAM: &str = "__finished";
|
||||||
|
pub const FINISHED_SHOWN: usize = 3;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
|
||||||
|
params
|
||||||
|
.get(FINISHED_PARAM)
|
||||||
|
.and_then(|n| n.parse().ok())
|
||||||
|
.unwrap_or(FINISHED_SHOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A desk is for the cases still moving. Every record still open stays;
|
||||||
|
/// of those in a final state (no move out), only the `shown` most
|
||||||
|
/// recently decided, plus one more so the page knows there are older
|
||||||
|
/// ones to offer. Each finished record is marked, so the page can tell
|
||||||
|
/// them apart without knowing the state machine.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub(crate) fn cap_finished(
|
||||||
|
items: Vec<serde_json::Value>,
|
||||||
|
schema: &crate::aggregates::AggregateSchema,
|
||||||
|
shown: usize,
|
||||||
|
) -> Vec<serde_json::Value> {
|
||||||
|
let is_final = |v: &serde_json::Value| {
|
||||||
|
v.get("state")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
|
||||||
|
};
|
||||||
|
let when = |v: &serde_json::Value| {
|
||||||
|
v.get("decided_ms")
|
||||||
|
.and_then(|d| d.as_i64())
|
||||||
|
.or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64()))
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
|
||||||
|
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
|
||||||
|
finished.truncate(shown + 1);
|
||||||
|
for v in &mut finished {
|
||||||
|
if let Some(obj) = v.as_object_mut() {
|
||||||
|
obj.insert("finished".into(), true.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open.extend(finished);
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches JSON from a path on the same Gitea instance content is
|
/// Fetches JSON from a path on the same Gitea instance content is
|
||||||
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
|
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
|
||||||
/// set - confirmed live against `project.uhhm.no` that at least the
|
/// set - confirmed live against `project.uhhm.no` that at least the
|
||||||
@@ -531,9 +652,16 @@ pub fn apply_jq(
|
|||||||
let input_val = read::parse_single(input_text.as_bytes())
|
let input_val = read::parse_single(input_text.as_bytes())
|
||||||
.map_err(|e| ServerFnError::new(format!("internal: re-parsing json for jq: {e:?}")))?;
|
.map_err(|e| ServerFnError::new(format!("internal: re-parsing json for jq: {e:?}")))?;
|
||||||
|
|
||||||
let program = File { code: filter_src, path: () };
|
let program = File {
|
||||||
let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs());
|
code: filter_src,
|
||||||
let funs = jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs());
|
path: (),
|
||||||
|
};
|
||||||
|
let defs = jaq_core::defs()
|
||||||
|
.chain(jaq_std::defs())
|
||||||
|
.chain(jaq_json::defs());
|
||||||
|
let funs = jaq_core::funs()
|
||||||
|
.chain(jaq_std::funs())
|
||||||
|
.chain(jaq_json::funs());
|
||||||
let loader = Loader::new(defs);
|
let loader = Loader::new(defs);
|
||||||
let arena = Arena::default();
|
let arena = Arena::default();
|
||||||
let modules = loader
|
let modules = loader
|
||||||
@@ -547,7 +675,8 @@ pub fn apply_jq(
|
|||||||
|
|
||||||
let mut outputs = Vec::new();
|
let mut outputs = Vec::new();
|
||||||
for result in filter.id.run((ctx, input_val)).map(unwrap_valr) {
|
for result in filter.id.run((ctx, input_val)).map(unwrap_valr) {
|
||||||
let val = result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
|
let val =
|
||||||
|
result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
|
||||||
let text = val.to_string();
|
let text = val.to_string();
|
||||||
let json: serde_json::Value = serde_json::from_str(&text)
|
let json: serde_json::Value = serde_json::from_str(&text)
|
||||||
.map_err(|e| ServerFnError::new(format!("internal: parsing jq output: {e}")))?;
|
.map_err(|e| ServerFnError::new(format!("internal: parsing jq output: {e}")))?;
|
||||||
@@ -573,16 +702,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_listing_can_be_narrowed_to_the_states_worth_showing() {
|
fn a_listing_can_be_narrowed_to_the_states_worth_showing() {
|
||||||
let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n");
|
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");
|
let decided = spec(
|
||||||
|
"source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n",
|
||||||
|
);
|
||||||
for state in ["approved", "refused", "withdrawn", "open"] {
|
for state in ["approved", "refused", "withdrawn", "open"] {
|
||||||
assert!(shows_state(&all, &record(state, serde_json::json!({}))));
|
assert!(shows_state(&all, &record(state, serde_json::json!({}))));
|
||||||
}
|
}
|
||||||
assert!(shows_state(&decided, &record("approved", serde_json::json!({}))));
|
assert!(shows_state(
|
||||||
assert!(shows_state(&decided, &record("refused", serde_json::json!({}))));
|
&decided,
|
||||||
assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({}))));
|
&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
|
// Something that is not an aggregate has no state to filter on
|
||||||
// and is left alone.
|
// and is left alone.
|
||||||
assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" })));
|
assert!(shows_state(
|
||||||
|
&decided,
|
||||||
|
&serde_json::json!({ "name": "a file" })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The other half of a record belonging to a record: the
|
// The other half of a record belonging to a record: the
|
||||||
@@ -591,9 +734,15 @@ mod tests {
|
|||||||
fn a_listing_can_be_narrowed_to_the_children_of_one_record() {
|
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 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 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 mine = record(
|
||||||
|
"open",
|
||||||
|
serde_json::json!({ "case": "abc123", "your_objection": "the sun" }),
|
||||||
|
);
|
||||||
let theirs = record("open", serde_json::json!({ "case": "def456" }));
|
let theirs = record("open", serde_json::json!({ "case": "def456" }));
|
||||||
let neither = record("open", serde_json::json!({ "your_objection": "no case named" }));
|
let neither = record(
|
||||||
|
"open",
|
||||||
|
serde_json::json!({ "your_objection": "no case named" }),
|
||||||
|
);
|
||||||
assert!(matches_filter(&children, &mine, ¶ms));
|
assert!(matches_filter(&children, &mine, ¶ms));
|
||||||
assert!(!matches_filter(&children, &theirs, ¶ms));
|
assert!(!matches_filter(&children, &theirs, ¶ms));
|
||||||
// A record that did not say is not everyone's child.
|
// A record that did not say is not everyone's child.
|
||||||
@@ -603,13 +752,25 @@ mod tests {
|
|||||||
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
|
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
|
||||||
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
|
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
|
||||||
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
|
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
|
||||||
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
let kari = crate::auth::User {
|
||||||
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
|
sub: "s".into(),
|
||||||
|
username: "kari".into(),
|
||||||
|
display_name: "Kari".into(),
|
||||||
|
groups: vec![],
|
||||||
|
phone: None,
|
||||||
|
email: Some("kari@vel.no".into()),
|
||||||
|
};
|
||||||
|
let forged: std::collections::HashMap<String, String> =
|
||||||
|
[(USER_EMAIL.to_string(), "ola@vel.no".to_string())]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
let asked = with_person(forged.clone(), Some(&kari));
|
let asked = with_person(forged.clone(), Some(&kari));
|
||||||
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
|
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
|
||||||
// Nobody signed in: the forged parameter is dropped, and nothing matches.
|
// Nobody signed in: the forged parameter is dropped, and nothing matches.
|
||||||
let anonymous = with_person(forged, None);
|
let anonymous = with_person(forged, None);
|
||||||
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
|
assert!(
|
||||||
|
!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous)
|
||||||
|
);
|
||||||
// No filter, no narrowing: every resource written before this
|
// No filter, no narrowing: every resource written before this
|
||||||
// existed still lists its whole bucket.
|
// existed still lists its whole bucket.
|
||||||
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
||||||
@@ -620,8 +781,74 @@ mod tests {
|
|||||||
fn a_filter_can_name_a_value_outright() {
|
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 fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n");
|
||||||
let params = std::collections::HashMap::new();
|
let params = std::collections::HashMap::new();
|
||||||
assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), ¶ms));
|
assert!(matches_filter(
|
||||||
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
&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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `limit` caps what the page renders, and does it after `jq` so
|
||||||
|
/// content can order with the filter and take the top of its own
|
||||||
|
/// ordering. The releases card is the case: the newest cut is the
|
||||||
|
/// one you should run, and a page that grows a card per release
|
||||||
|
/// gets longer forever.
|
||||||
|
#[test]
|
||||||
|
fn limit_caps_a_list_after_the_filter() {
|
||||||
|
let releases = serde_json::json!([
|
||||||
|
{"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"},
|
||||||
|
{"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"},
|
||||||
|
{"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"}
|
||||||
|
]);
|
||||||
|
// The filter redoal/questions actually declares: bare objects,
|
||||||
|
// never a wrapped array — the frontend re-wraps.
|
||||||
|
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}";
|
||||||
|
let shaped = apply_jq(filter, &releases).expect("filter runs");
|
||||||
|
|
||||||
|
let all = apply_limit(None, shaped.clone());
|
||||||
|
assert_eq!(
|
||||||
|
all.as_array().map(Vec::len),
|
||||||
|
Some(3),
|
||||||
|
"no limit shows everything"
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest = apply_limit(Some(1), shaped.clone());
|
||||||
|
let rows = latest.as_array().expect("still a list");
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
rows[0]["tag"], "v0.1.17",
|
||||||
|
"and it is the newest, which Gitea returns first"
|
||||||
|
);
|
||||||
|
|
||||||
|
let two = apply_limit(Some(2), shaped.clone());
|
||||||
|
assert_eq!(two.as_array().map(Vec::len), Some(2));
|
||||||
|
|
||||||
|
// Asking for more than there is is not an error.
|
||||||
|
let plenty = apply_limit(Some(99), shaped);
|
||||||
|
assert_eq!(plenty.as_array().map(Vec::len), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A limit that cannot apply is ignored rather than guessed at.
|
||||||
|
#[test]
|
||||||
|
fn limit_leaves_a_single_record_alone() {
|
||||||
|
let one = serde_json::json!({"name": "a record", "state": "open"});
|
||||||
|
assert_eq!(apply_limit(Some(1), one.clone()), one);
|
||||||
|
assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap");
|
||||||
|
|
||||||
|
// Zero on a list does mean nothing — the `empty` line speaks.
|
||||||
|
let list = serde_json::json!([{"a": 1}, {"a": 2}]);
|
||||||
|
assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A canned, Gitea-API-shaped fixture - the same fields the real
|
/// A canned, Gitea-API-shaped fixture - the same fields the real
|
||||||
@@ -718,7 +945,11 @@ mod tests {
|
|||||||
assert_eq!(items[0]["name"], "varde bring-up");
|
assert_eq!(items[0]["name"], "varde bring-up");
|
||||||
assert_eq!(items[0]["tag"], "v0.3.0");
|
assert_eq!(items[0]["tag"], "v0.3.0");
|
||||||
assert_eq!(items[0]["description"], "Embedded varde-core on iOS.");
|
assert_eq!(items[0]["description"], "Embedded varde-core on iOS.");
|
||||||
assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private");
|
assert_eq!(
|
||||||
|
items[0]["url"],
|
||||||
|
serde_json::Value::Null,
|
||||||
|
"no public link while private"
|
||||||
|
);
|
||||||
assert!(items[0].get("html_url").is_none());
|
assert!(items[0].get("html_url").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,8 +971,7 @@ mod tests {
|
|||||||
("path".to_string(), "questions/a b.yaml".to_string()),
|
("path".to_string(), "questions/a b.yaml".to_string()),
|
||||||
("ref".to_string(), "main".to_string()),
|
("ref".to_string(), "main".to_string()),
|
||||||
]);
|
]);
|
||||||
let (url, leftover) =
|
let (url, leftover) = template_url("https://x.example/api/contents/{path}", ¶ms);
|
||||||
template_url("https://x.example/api/contents/{path}", ¶ms);
|
|
||||||
// `/` survives, the space percent-encodes, and the param that
|
// `/` survives, the space percent-encodes, and the param that
|
||||||
// matched a placeholder is consumed.
|
// matched a placeholder is consumed.
|
||||||
assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml");
|
assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml");
|
||||||
@@ -758,3 +988,38 @@ mod tests {
|
|||||||
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "ssr"))]
|
||||||
|
mod finished_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
|
||||||
|
let schema = crate::aggregates::parse_aggregates_yaml(
|
||||||
|
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.remove("leads")
|
||||||
|
.unwrap();
|
||||||
|
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
|
||||||
|
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
|
||||||
|
items.extend(
|
||||||
|
(1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)),
|
||||||
|
);
|
||||||
|
let ids = |v: &[serde_json::Value]| {
|
||||||
|
v.iter()
|
||||||
|
.map(|x| x["id"].as_str().unwrap().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let shown = cap_finished(items.clone(), &schema, 3);
|
||||||
|
// Both open cases, then the three newest finished and one more
|
||||||
|
// to tell the page there are older ones.
|
||||||
|
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
|
||||||
|
assert!(shown[2..].iter().all(|v| v["finished"] == true));
|
||||||
|
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
|
||||||
|
|
||||||
|
// Asking for all of them: no extra, so no "show older".
|
||||||
|
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+200
-47
@@ -58,10 +58,15 @@ pub const SEED_BUCKET: &str = "portal_seed";
|
|||||||
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
|
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
|
||||||
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||||
for q in questions.values() {
|
for q in questions.values() {
|
||||||
let Some(group) = q.qualifies.as_deref() else { continue };
|
let Some(group) = q.qualifies.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
for alt in &q.alternatives {
|
for alt in &q.alternatives {
|
||||||
if let Some(invite) = &alt.invite {
|
if let Some(invite) = &alt.invite {
|
||||||
reach.entry(group).or_default().insert(invite.group.as_str());
|
reach
|
||||||
|
.entry(group)
|
||||||
|
.or_default()
|
||||||
|
.insert(invite.group.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +78,19 @@ pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<St
|
|||||||
|
|
||||||
/// The bucket key for one seed: stable, and within NATS key rules.
|
/// The bucket key for one seed: stable, and within NATS key rules.
|
||||||
pub fn seed_key(email: &str, group: &str) -> String {
|
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>();
|
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))
|
format!("{}.{}", clean(group), clean(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +99,9 @@ pub fn seed_key(email: &str, group: &str) -> String {
|
|||||||
/// questions they answer for, in page order. A page a group answers
|
/// questions they answer for, in page order. A page a group answers
|
||||||
/// for (`responsible.group`) names nobody: the board's inbox is not a
|
/// for (`responsible.group`) names nobody: the board's inbox is not a
|
||||||
/// person, and its members have their accounts through the desk.
|
/// person, and its members have their accounts through the desk.
|
||||||
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
|
pub fn responsible_people(
|
||||||
|
questions: &HashMap<String, Question>,
|
||||||
|
) -> BTreeMap<String, (String, Vec<String>)> {
|
||||||
let mut ids: Vec<&String> = questions.keys().collect();
|
let mut ids: Vec<&String> = questions.keys().collect();
|
||||||
ids.sort();
|
ids.sort();
|
||||||
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
|
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
|
||||||
@@ -94,7 +113,9 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
|
|||||||
}
|
}
|
||||||
let email = contact_email(&r.contact);
|
let email = contact_email(&r.contact);
|
||||||
let Some(email) = email else { continue };
|
let Some(email) = email else { continue };
|
||||||
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
let entry = out
|
||||||
|
.entry(email)
|
||||||
|
.or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
||||||
if !entry.1.contains(&q.name) {
|
if !entry.1.contains(&q.name) {
|
||||||
entry.1.push(q.name.clone());
|
entry.1.push(q.name.clone());
|
||||||
}
|
}
|
||||||
@@ -105,7 +126,11 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
|
|||||||
/// A responsible `contact` as an address: `mailto:` off, lowercased;
|
/// A responsible `contact` as an address: `mailto:` off, lowercased;
|
||||||
/// `None` for a phone number or anything else that is not one.
|
/// `None` for a phone number or anything else that is not one.
|
||||||
pub fn contact_email(contact: &str) -> Option<String> {
|
pub fn contact_email(contact: &str) -> Option<String> {
|
||||||
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
let c = contact
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("mailto:")
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +151,10 @@ static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|||||||
pub async fn run(state: crate::server::AppState) {
|
pub async fn run(state: crate::server::AppState) {
|
||||||
// Subscribed before the first pass, so content that changes while
|
// Subscribed before the first pass, so content that changes while
|
||||||
// it runs is not missed.
|
// it runs is not missed.
|
||||||
let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await;
|
let reloads = state
|
||||||
|
.nats
|
||||||
|
.subscribe(crate::content::CONTENT_RELOADED_SUBJECT)
|
||||||
|
.await;
|
||||||
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
|
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
|
||||||
let mut reloads = match reloads {
|
let mut reloads = match reloads {
|
||||||
Ok(reloads) => reloads,
|
Ok(reloads) => reloads,
|
||||||
@@ -158,7 +186,11 @@ async fn responsible_until_done(state: &crate::server::AppState) {
|
|||||||
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
|
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute");
|
tracing::warn!(
|
||||||
|
left,
|
||||||
|
attempt,
|
||||||
|
"some responsible people could not be set up, trying again in a minute"
|
||||||
|
);
|
||||||
tokio::time::sleep(APART).await;
|
tokio::time::sleep(APART).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +198,14 @@ async fn responsible_until_done(state: &crate::server::AppState) {
|
|||||||
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
||||||
match state.jetstream.get_key_value(SEED_BUCKET).await {
|
match state.jetstream.get_key_value(SEED_BUCKET).await {
|
||||||
Ok(store) => Some(store),
|
Ok(store) => Some(store),
|
||||||
Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
|
Err(_) => match state
|
||||||
|
.jetstream
|
||||||
|
.create_key_value(async_nats::jetstream::kv::Config {
|
||||||
|
bucket: SEED_BUCKET.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(store) => Some(store),
|
Ok(store) => Some(store),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
|
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
|
||||||
@@ -177,7 +216,10 @@ async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jets
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seed_admin_email() -> Option<String> {
|
fn seed_admin_email() -> Option<String> {
|
||||||
std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@'))
|
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
|
/// Who portal put in the responsible group and the content no longer
|
||||||
@@ -204,24 +246,37 @@ pub fn no_longer_responsible(
|
|||||||
/// it could not finish with.
|
/// it could not finish with.
|
||||||
async fn responsible(state: &crate::server::AppState) -> usize {
|
async fn responsible(state: &crate::server::AppState) -> usize {
|
||||||
let people = responsible_people(&state.questions.load());
|
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());
|
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() {
|
if people.is_empty() && group.is_none() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let Some(people_backend) = crate::people::People::from_env() else {
|
let Some(people_backend) = crate::people::People::from_env() else {
|
||||||
if !people.is_empty() {
|
if !people.is_empty() {
|
||||||
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
|
tracing::info!(
|
||||||
|
people = people.len(),
|
||||||
|
"the content names responsible people, but {} - no accounts made",
|
||||||
|
crate::people::People::NOT_CONFIGURED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
let Some(store) = seed_bucket(state).await else { return 0 };
|
let Some(store) = seed_bucket(state).await else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
let mut left = 0;
|
let mut left = 0;
|
||||||
for (email, (name, questions)) in &people {
|
for (email, (name, questions)) in &people {
|
||||||
let key = seed_key(email, group.as_deref().unwrap_or("responsible"));
|
let key = seed_key(email, group.as_deref().unwrap_or("responsible"));
|
||||||
if let Ok(Some(_)) = store.get(&key).await {
|
if let Ok(Some(_)) = store.get(&key).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
|
let name = if name.is_empty() {
|
||||||
|
email.split('@').next().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
name.clone()
|
||||||
|
};
|
||||||
let who = crate::memberships::Identity::from_contact(email);
|
let who = crate::memberships::Identity::from_contact(email);
|
||||||
// With a group, one call makes the account if there is none and
|
// With a group, one call makes the account if there is none and
|
||||||
// adds them either way; without one, only a missing account is
|
// adds them either way; without one, only a missing account is
|
||||||
@@ -229,21 +284,30 @@ async fn responsible(state: &crate::server::AppState) -> usize {
|
|||||||
// way a person is mailed only when an account was made for
|
// way a person is mailed only when an account was made for
|
||||||
// them: someone who had one already has their way in.
|
// them: someone who had one already has their way in.
|
||||||
let made = match (&group, &people_backend) {
|
let made = match (&group, &people_backend) {
|
||||||
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
|
(Some(group), people_backend) => {
|
||||||
(None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await {
|
people_backend
|
||||||
Ok(true) => {
|
.invite(state, &name, &who, group, "responsible")
|
||||||
let _ = store.put(&key, "existing".into()).await;
|
.await
|
||||||
continue;
|
}
|
||||||
|
(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),
|
||||||
}
|
}
|
||||||
Ok(false) => kanidm.account(&name, email).await,
|
}
|
||||||
Err(e) => Err(e),
|
|
||||||
},
|
|
||||||
(None, crate::people::People::Memberships) => continue,
|
(None, crate::people::People::Memberships) => continue,
|
||||||
};
|
};
|
||||||
match made {
|
match made {
|
||||||
Ok(invited) => {
|
Ok(invited) => {
|
||||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
let _ = state
|
||||||
|
.nats
|
||||||
|
.publish(crate::people::INVITED_SUBJECT, bytes.clone().into())
|
||||||
|
.await;
|
||||||
let _ = store.put(&key, bytes.into()).await;
|
let _ = store.put(&key, bytes.into()).await;
|
||||||
}
|
}
|
||||||
if invited.created {
|
if invited.created {
|
||||||
@@ -274,14 +338,21 @@ async fn responsible(state: &crate::server::AppState) -> usize {
|
|||||||
};
|
};
|
||||||
let mut on_file = Vec::new();
|
let mut on_file = Vec::new();
|
||||||
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
|
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
|
||||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
let Ok(Some(bytes)) = store.get(&key).await else {
|
||||||
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else { continue };
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
on_file.push((key, invited));
|
on_file.push((key, invited));
|
||||||
}
|
}
|
||||||
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
|
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
|
||||||
if !invited.already_member {
|
if !invited.already_member {
|
||||||
let who = crate::memberships::Identity::from_contact(&invited.email);
|
let who = crate::memberships::Identity::from_contact(&invited.email);
|
||||||
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await {
|
if let Err(e) = people_backend
|
||||||
|
.remove(state, &who, &invited.username, &group, "responsible")
|
||||||
|
.await
|
||||||
|
{
|
||||||
left += 1;
|
left += 1;
|
||||||
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
|
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
|
||||||
continue;
|
continue;
|
||||||
@@ -298,13 +369,24 @@ async fn responsible(state: &crate::server::AppState) -> usize {
|
|||||||
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
|
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
|
||||||
/// their number.
|
/// their number.
|
||||||
async fn seed_admin(state: &crate::server::AppState) {
|
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 phone = std::env::var("SEED_ADMIN_PHONE")
|
||||||
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
|
.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) = (
|
let (Some(contact), who) = (
|
||||||
phone.clone().or_else(|| email.clone()),
|
phone.clone().or_else(|| email.clone()),
|
||||||
crate::memberships::Identity {
|
crate::memberships::Identity {
|
||||||
sub: None,
|
sub: None,
|
||||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
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()),
|
email: email.as_deref().map(|e| e.to_lowercase()),
|
||||||
},
|
},
|
||||||
) else {
|
) else {
|
||||||
@@ -316,7 +398,11 @@ async fn seed_admin(state: &crate::server::AppState) {
|
|||||||
.map(|n| n.trim().to_string())
|
.map(|n| n.trim().to_string())
|
||||||
.filter(|n| !n.is_empty())
|
.filter(|n| !n.is_empty())
|
||||||
.unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
|
.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()) {
|
let group = match std::env::var("SEED_GROUP")
|
||||||
|
.ok()
|
||||||
|
.map(|g| g.trim().to_string())
|
||||||
|
.filter(|g| !g.is_empty())
|
||||||
|
{
|
||||||
Some(g) => g,
|
Some(g) => g,
|
||||||
None => match most_privileged_group(&state.questions.load()) {
|
None => match most_privileged_group(&state.questions.load()) {
|
||||||
Some(g) => g,
|
Some(g) => g,
|
||||||
@@ -327,13 +413,22 @@ async fn seed_admin(state: &crate::server::AppState) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let key = seed_key(&email, &group);
|
let key = seed_key(&email, &group);
|
||||||
let Some(store) = seed_bucket(state).await else { return };
|
let Some(store) = seed_bucket(state).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if let Ok(Some(_)) = store.get(&key).await {
|
if let Ok(Some(_)) = store.get(&key).await {
|
||||||
tracing::info!(group, "the first person was already invited into this group; not again");
|
tracing::info!(
|
||||||
|
group,
|
||||||
|
"the first person was already invited into this group; not again"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(people) = crate::people::People::from_env() else {
|
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);
|
tracing::warn!(
|
||||||
|
group,
|
||||||
|
"SEED_ADMIN_EMAIL is set, but {} - nobody was seeded",
|
||||||
|
crate::people::People::NOT_CONFIGURED
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,7 +438,11 @@ async fn seed_admin(state: &crate::server::AppState) {
|
|||||||
match people.invite(state, &name, &who, &group, "seed").await {
|
match people.invite(state, &name, &who, &group, "seed").await {
|
||||||
Ok(invited) => break invited,
|
Ok(invited) => break invited,
|
||||||
Err(e) if attempt < ATTEMPTS => {
|
Err(e) if attempt < ATTEMPTS => {
|
||||||
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
|
tracing::warn!(
|
||||||
|
group,
|
||||||
|
attempt,
|
||||||
|
"seeding the first person failed, trying again in a minute: {e}"
|
||||||
|
);
|
||||||
tokio::time::sleep(APART).await;
|
tokio::time::sleep(APART).await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -353,7 +452,10 @@ async fn seed_admin(state: &crate::server::AppState) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
let _ = state
|
||||||
|
.nats
|
||||||
|
.publish(crate::people::INVITED_SUBJECT, bytes.clone().into())
|
||||||
|
.await;
|
||||||
if let Err(e) = store.put(&key, bytes.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");
|
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
|
||||||
}
|
}
|
||||||
@@ -374,7 +476,17 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
|
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
|
||||||
.collect();
|
.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 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();
|
let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
|
||||||
q.id = id.to_string();
|
q.id = id.to_string();
|
||||||
(id.to_string(), q)
|
(id.to_string(), q)
|
||||||
@@ -384,7 +496,11 @@ mod tests {
|
|||||||
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
|
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
|
||||||
let qs: HashMap<String, Question> = [
|
let qs: HashMap<String, Question> = [
|
||||||
page("/", None, &[]),
|
page("/", None, &[]),
|
||||||
page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
|
page(
|
||||||
|
"/styret",
|
||||||
|
Some("styret"),
|
||||||
|
&["styret", "kasserer", "trafikk", "komiteer"],
|
||||||
|
),
|
||||||
page("/kasserer", Some("kasserer"), &["kasserer"]),
|
page("/kasserer", Some("kasserer"), &["kasserer"]),
|
||||||
page("/trafikk", Some("trafikk"), &["trafikk"]),
|
page("/trafikk", Some("trafikk"), &["trafikk"]),
|
||||||
]
|
]
|
||||||
@@ -392,7 +508,10 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
|
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
|
||||||
// A tie goes to the name that sorts first, every time.
|
// 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();
|
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"));
|
assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
|
||||||
// No invitations anywhere: nobody to seed into without SEED_GROUP.
|
// No invitations anywhere: nobody to seed into without SEED_GROUP.
|
||||||
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
|
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
|
||||||
@@ -419,26 +538,54 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() {
|
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 people: BTreeMap<String, (String, Vec<String>)> = [(
|
||||||
let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)];
|
"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"));
|
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.iter()
|
||||||
|
.map(|(_, i)| i.username.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["Ola"]
|
||||||
|
);
|
||||||
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
|
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
|
||||||
// Nobody named at all: everyone portal put there leaves.
|
// Nobody named at all: everyone portal put there leaves.
|
||||||
assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3);
|
assert_eq!(
|
||||||
|
no_longer_responsible(&filed, &BTreeMap::new(), None).len(),
|
||||||
|
3
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn everyone_named_responsible_is_found_once_by_their_address() {
|
fn everyone_named_responsible_is_found_once_by_their_address() {
|
||||||
let mut qs: HashMap<String, Question> = HashMap::new();
|
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")] {
|
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();
|
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();
|
q.id = id.to_string();
|
||||||
qs.insert(id.to_string(), q);
|
qs.insert(id.to_string(), q);
|
||||||
}
|
}
|
||||||
let people = responsible_people(&qs);
|
let people = responsible_people(&qs);
|
||||||
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
|
assert_eq!(
|
||||||
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
|
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);
|
assert_eq!(contact_email("+47 900 00 000"), None);
|
||||||
// A group's inbox is nobody: its members have the desk.
|
// 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();
|
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();
|
||||||
@@ -449,7 +596,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_seed_is_remembered_by_address_and_group() {
|
fn a_seed_is_remembered_by_address_and_group() {
|
||||||
assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
|
assert_eq!(
|
||||||
assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
|
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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -28,7 +28,9 @@ pub struct AppState {
|
|||||||
/// hot-swapped alongside `questions` on the same reload. A bucket
|
/// hot-swapped alongside `questions` on the same reload. A bucket
|
||||||
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
||||||
/// still works (see `answers.rs`).
|
/// still works (see `answers.rs`).
|
||||||
pub aggregates: Arc<ArcSwap<crate::content::Storylines<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`
|
/// Site branding from the content repo's optional `site.yaml`
|
||||||
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
||||||
/// `aggregates` on the same reload. Default = the historical uhhm
|
/// `aggregates` on the same reload. Default = the historical uhhm
|
||||||
|
|||||||
+32
-14
@@ -13,8 +13,8 @@ use axum::{
|
|||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
|
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
|
||||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
|
||||||
OAuth2TokenResponse, TokenResponse,
|
RedirectUrl, Scope, TokenResponse,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
@@ -148,7 +148,10 @@ pub async fn login(
|
|||||||
Query(params): Query<LoginParams>,
|
Query(params): Query<LoginParams>,
|
||||||
) -> Result<Redirect, HandlerError> {
|
) -> Result<Redirect, HandlerError> {
|
||||||
if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) {
|
if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) {
|
||||||
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
|
session
|
||||||
|
.insert(REDIRECT_KEY, redirect)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let oidc = state.oidc.configured()?;
|
let oidc = state.oidc.configured()?;
|
||||||
@@ -168,7 +171,10 @@ pub async fn login(
|
|||||||
// What else to ask the provider for: `phoneNumber` for one that
|
// What else to ask the provider for: `phoneNumber` for one that
|
||||||
// verifies numbers (Vipps). Only when asked, because Kanidm denies
|
// verifies numbers (Vipps). Only when asked, because Kanidm denies
|
||||||
// a sign-in that requests a scope the person does not hold.
|
// 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() {
|
for scope in std::env::var("OIDC_EXTRA_SCOPES")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split_whitespace()
|
||||||
|
{
|
||||||
request = request.add_scope(Scope::new(scope.to_string()));
|
request = request.add_scope(Scope::new(scope.to_string()));
|
||||||
}
|
}
|
||||||
let (auth_url, csrf_state, nonce) = request.url();
|
let (auth_url, csrf_state, nonce) = request.url();
|
||||||
@@ -206,8 +212,7 @@ pub async fn callback(
|
|||||||
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
|
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
|
||||||
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
|
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
|
||||||
|
|
||||||
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
|
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) = (stored_csrf, pkce_verifier, nonce)
|
||||||
(stored_csrf, pkce_verifier, nonce)
|
|
||||||
else {
|
else {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -262,12 +267,19 @@ pub async fn callback(
|
|||||||
// The verified phone number lives at userinfo, not in the ID token,
|
// The verified phone number lives at userinfo, not in the ID token,
|
||||||
// for providers that give one (Vipps). A provider that has none, or
|
// for providers that give one (Vipps). A provider that has none, or
|
||||||
// no userinfo endpoint, leaves it empty; nothing depends on it.
|
// no userinfo endpoint, leaves it empty; nothing depends on it.
|
||||||
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
|
let phone = match oidc
|
||||||
Ok(request) => request
|
.client
|
||||||
.request_async(&oidc.http)
|
.user_info(token_response.access_token().clone(), None)
|
||||||
.await
|
{
|
||||||
.ok()
|
Ok(request) => {
|
||||||
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
|
request
|
||||||
|
.request_async(&oidc.http)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|info: CoreUserInfoClaims| {
|
||||||
|
info.phone_number().map(|p| p.as_str().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
let mut user = User {
|
let mut user = User {
|
||||||
@@ -295,7 +307,9 @@ pub async fn callback(
|
|||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
|
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());
|
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
|
// Being here is what an invite was for (people.rs): off the
|
||||||
// request, since nobody signing in should wait on a desk's list.
|
// request, since nobody signing in should wait on a desk's list.
|
||||||
@@ -338,6 +352,10 @@ fn extract_groups_claim<T: serde::Serialize>(id_token: &T) -> Vec<String> {
|
|||||||
payload
|
payload
|
||||||
.get("groups")
|
.get("groups")
|
||||||
.and_then(|g| g.as_array())
|
.and_then(|g| g.as_array())
|
||||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-5
@@ -21,7 +21,10 @@ fn from_cookies(header: Option<&str>, arity: usize) -> Option<usize> {
|
|||||||
header?
|
header?
|
||||||
.split(';')
|
.split(';')
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.find_map(|kv| kv.strip_prefix(COOKIE_NAME).and_then(|rest| rest.strip_prefix('=')))
|
.find_map(|kv| {
|
||||||
|
kv.strip_prefix(COOKIE_NAME)
|
||||||
|
.and_then(|rest| rest.strip_prefix('='))
|
||||||
|
})
|
||||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||||
.filter(|n| *n < arity)
|
.filter(|n| *n < arity)
|
||||||
}
|
}
|
||||||
@@ -31,14 +34,23 @@ fn pick(arity: usize) -> usize {
|
|||||||
(uuid::Uuid::new_v4().as_u128() % arity.max(1) as u128) as 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 {
|
pub async fn assign(
|
||||||
|
State(state): State<super::AppState>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
let arity = state.questions.load().arity();
|
let arity = state.questions.load().arity();
|
||||||
let held = from_cookies(req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), 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));
|
let n = held.unwrap_or_else(|| pick(arity));
|
||||||
req.extensions_mut().insert(Storyline(n));
|
req.extensions_mut().insert(Storyline(n));
|
||||||
let mut res = next.run(req).await;
|
let mut res = next.run(req).await;
|
||||||
if held.is_none() && arity > 1 {
|
if held.is_none() && arity > 1 {
|
||||||
if let Ok(value) = format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() {
|
if let Ok(value) =
|
||||||
|
format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse()
|
||||||
|
{
|
||||||
res.headers_mut().append(SET_COOKIE, value);
|
res.headers_mut().append(SET_COOKIE, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +72,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cookie_names_a_storyline_the_site_tells() {
|
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("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_storyline=7"), 3), None);
|
||||||
assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None);
|
assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None);
|
||||||
assert_eq!(from_cookies(None, 3), None);
|
assert_eq!(from_cookies(None, 3), None);
|
||||||
|
|||||||
+8
-2
@@ -37,7 +37,9 @@ pub struct NatsStore {
|
|||||||
|
|
||||||
impl std::fmt::Debug for NatsStore {
|
impl std::fmt::Debug for NatsStore {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("NatsStore").field("bucket", &BUCKET).finish()
|
f.debug_struct("NatsStore")
|
||||||
|
.field("bucket", &BUCKET)
|
||||||
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,11 @@ impl tower_sessions::SessionStore for NatsStore {
|
|||||||
async fn create(&self, record: &mut Record) -> Result<()> {
|
async fn create(&self, record: &mut Record) -> Result<()> {
|
||||||
for _ in 0..ID_ATTEMPTS {
|
for _ in 0..ID_ATTEMPTS {
|
||||||
let bytes = Self::encode(record)?;
|
let bytes = Self::encode(record)?;
|
||||||
match self.store.create(&record.id.to_string(), bytes.into()).await {
|
match self
|
||||||
|
.store
|
||||||
|
.create(&record.id.to_string(), bytes.into())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => return Ok(()),
|
Ok(_) => return Ok(()),
|
||||||
Err(_) => record.id = Id::default(),
|
Err(_) => record.id = Id::default(),
|
||||||
}
|
}
|
||||||
|
|||||||
+1628
-403
File diff suppressed because it is too large
Load Diff
+34
-7
@@ -27,23 +27,42 @@ pub fn alternative(alt: &Value, full: bool) -> String {
|
|||||||
out.push_str(&format!(": {d}"));
|
out.push_str(&format!(": {d}"));
|
||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) {
|
if let Some(button) = alt["consequence"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|c| c.first())
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
{
|
||||||
out.push_str(&format!(" Button: {button}\n"));
|
out.push_str(&format!(" Button: {button}\n"));
|
||||||
}
|
}
|
||||||
for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
for e in alt["encouragements"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
{
|
||||||
out.push_str(&format!(" {e}\n"));
|
out.push_str(&format!(" {e}\n"));
|
||||||
}
|
}
|
||||||
for f in alt["features"].as_array().into_iter().flatten() {
|
for f in alt["features"].as_array().into_iter().flatten() {
|
||||||
let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim());
|
let (name, desc, icon) = (
|
||||||
|
s(&f["name"]).trim(),
|
||||||
|
s(&f["description"]).trim(),
|
||||||
|
s(&f["icon"]).trim(),
|
||||||
|
);
|
||||||
let mut line = String::from(" ");
|
let mut line = String::from(" ");
|
||||||
if !icon.is_empty() {
|
if !icon.is_empty() {
|
||||||
line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon)));
|
line.push_str(&format!(
|
||||||
|
"[icon {}] ",
|
||||||
|
icon.rsplit(':').next().unwrap_or(icon)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
line.push_str(name);
|
line.push_str(name);
|
||||||
}
|
}
|
||||||
if !desc.is_empty() {
|
if !desc.is_empty() {
|
||||||
line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " }));
|
line.push_str(&format!(
|
||||||
|
"{}{desc}",
|
||||||
|
if name.is_empty() { "" } else { ": " }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !line.trim().is_empty() {
|
if !line.trim().is_empty() {
|
||||||
out.push_str(line.trim_end());
|
out.push_str(line.trim_end());
|
||||||
@@ -56,8 +75,16 @@ pub fn alternative(alt: &Value, full: bool) -> String {
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
|
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
|
||||||
let hint = s(&r["placeholder"]).trim();
|
let hint = s(&r["placeholder"]).trim();
|
||||||
let opt = if r["optional"] == true { " (optional)" } else { "" };
|
let opt = if r["optional"] == true {
|
||||||
if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") }
|
" (optional)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if hint.is_empty() {
|
||||||
|
format!("{label}{opt}")
|
||||||
|
} else {
|
||||||
|
format!("{label}{opt} e.g. {hint}")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if !fields.is_empty() {
|
if !fields.is_empty() {
|
||||||
|
|||||||
+455
-29
@@ -74,7 +74,15 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
// sampling, so a score comes with its spread. 0 is the run
|
// sampling, so a score comes with its spread. 0 is the run
|
||||||
// every earlier score used.
|
// every earlier score used.
|
||||||
let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref(), seed)?);
|
print!(
|
||||||
|
"{}",
|
||||||
|
answer_blind(
|
||||||
|
&cmd,
|
||||||
|
&std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?,
|
||||||
|
repo.as_deref(),
|
||||||
|
seed
|
||||||
|
)?
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let opts = parse_args()?;
|
let opts = parse_args()?;
|
||||||
@@ -103,11 +111,16 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
std::fs::write(opts.out.join("needs.yaml"), needs)?;
|
std::fs::write(opts.out.join("needs.yaml"), needs)?;
|
||||||
// A placeholder front door, so the lint gets as far as parsing
|
// A placeholder front door, so the lint gets as far as parsing
|
||||||
// the spec; every need fails, a parse error is what we look for.
|
// the spec; every need fails, a parse error is what we look for.
|
||||||
std::fs::write(opts.out.join("questions/index.yaml"), "name: Placeholder?\n")?;
|
std::fs::write(
|
||||||
|
opts.out.join("questions/index.yaml"),
|
||||||
|
"name: Placeholder?\n",
|
||||||
|
)?;
|
||||||
let (_, report) = lint(&opts, &[])?;
|
let (_, report) = lint(&opts, &[])?;
|
||||||
if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) {
|
if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) {
|
||||||
eprintln!("needs round {round}: {line}");
|
eprintln!("needs round {round}: {line}");
|
||||||
feedback = format!("\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n");
|
feedback = format!(
|
||||||
|
"\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
eprintln!("needs round {round}: needs.yaml parses, frozen");
|
eprintln!("needs round {round}: needs.yaml parses, frozen");
|
||||||
@@ -116,9 +129,14 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if !feedback.is_empty() {
|
if !feedback.is_empty() {
|
||||||
bail!("needs.yaml never parsed in {} round(s); the replies are in {}/llm/", opts.rounds, opts.out.display());
|
bail!(
|
||||||
|
"needs.yaml never parsed in {} round(s); the replies are in {}/llm/",
|
||||||
|
opts.rounds,
|
||||||
|
opts.out.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?;
|
let needs = std::fs::read_to_string(opts.out.join("needs.yaml"))
|
||||||
|
.context("no needs.yaml was produced")?;
|
||||||
|
|
||||||
// Phase 2: structure. The page-writing model sees the needs and
|
// Phase 2: structure. The page-writing model sees the needs and
|
||||||
// never the personas: pages written with the test in view route
|
// never the personas: pages written with the test in view route
|
||||||
@@ -132,34 +150,120 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
let mut green = false;
|
let mut green = false;
|
||||||
let mut report = String::new();
|
let mut report = String::new();
|
||||||
|
// What the lint found, by id, round over round (`--findings`): what
|
||||||
|
// a round closed, what it left, what it broke. `None` while the
|
||||||
|
// lint is one that predates `--findings`; then the whole report
|
||||||
|
// goes back, as it always did.
|
||||||
|
let mut ledger = Ledger::default();
|
||||||
|
let mut tracked = true;
|
||||||
|
let mut undone = String::new();
|
||||||
|
std::fs::create_dir_all(opts.out.join("findings"))?;
|
||||||
for round in 1..=opts.rounds {
|
for round in 1..=opts.rounds {
|
||||||
let prompt = if round == 1 {
|
let prompt = if round == 1 {
|
||||||
format!(
|
format!(
|
||||||
"{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}"
|
"{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}"
|
||||||
)
|
)
|
||||||
|
} else if tracked {
|
||||||
|
let (heading, files) = match named_files(&opts.out, ledger.open.values()) {
|
||||||
|
Some(files) => ("Files the findings name", dump_files(&opts.out, &files)?),
|
||||||
|
None => ("Current files", dump(&opts.out)?),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo. Below is every finding still open, each with the change that closes it. Make those changes and nothing else: findings already closed stay closed. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Open findings\n\n{}\n{undone}## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## {heading}\n\n{files}\n\n{FORMAT}",
|
||||||
|
ledger.open_list(round)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
|
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
|
||||||
dump(&opts.out)?
|
dump(&opts.out)?
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let before = snapshot(&opts.out)?;
|
||||||
match parse_files(&llm(&opts.llm, &prompt)?) {
|
match parse_files(&llm(&opts.llm, &prompt)?) {
|
||||||
Ok(files) => apply(&opts.out, files)?,
|
Ok(files) => apply(&opts.out, files)?,
|
||||||
Err(_) => eprintln!("structure round {round}: the reply had no file blocks"),
|
Err(_) => eprintln!("structure round {round}: the reply had no file blocks"),
|
||||||
}
|
}
|
||||||
let (ok, r) = lint(&opts, &[])?;
|
let (ok, r, found) = lint_findings(
|
||||||
|
&opts,
|
||||||
|
&opts.out.join(format!("findings/round-{round}.jsonl")),
|
||||||
|
)?;
|
||||||
report = r;
|
report = r;
|
||||||
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
let Some(found) = found else {
|
||||||
eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") });
|
tracked = false;
|
||||||
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
||||||
|
eprintln!(
|
||||||
|
"structure round {round}: {}",
|
||||||
|
if ok {
|
||||||
|
"green".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{fails} FAIL line(s)")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
||||||
|
if ok {
|
||||||
|
green = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let previous = ledger.clone();
|
||||||
|
let progress = ledger.update(
|
||||||
|
round,
|
||||||
|
found.into_iter().filter(|f| f.level == "fail").collect(),
|
||||||
|
);
|
||||||
|
let line = format!(
|
||||||
|
"structure round {round}: {}",
|
||||||
|
progress.summary(ledger.open.len())
|
||||||
|
);
|
||||||
|
eprintln!("{line}");
|
||||||
|
log.push(line);
|
||||||
|
undone.clear();
|
||||||
if ok {
|
if ok {
|
||||||
green = true;
|
green = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if round == 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A fix that reopens what an earlier round closed is undone,
|
||||||
|
// and the model is told what it broke, rather than chasing the
|
||||||
|
// same findings round after round.
|
||||||
|
if !progress.reopened.is_empty() {
|
||||||
|
restore(&opts.out, before)?;
|
||||||
|
ledger = previous;
|
||||||
|
undone = format!(
|
||||||
|
"## Your last change was undone\n\nIt reopened findings an earlier round had closed:\n\n{}\nClose the open findings without touching what made these pass.\n\n",
|
||||||
|
progress.reopened.iter().map(|f| format!("- [{}] {}\n", f.id, f.message)).collect::<String>()
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"structure round {round}: reopened {} closed finding(s), undone",
|
||||||
|
progress.reopened.len()
|
||||||
|
);
|
||||||
|
log.push(format!(
|
||||||
|
"structure round {round}: undone (reopened {})",
|
||||||
|
progress.reopened.len()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// No progress, no more rounds: another pass at the same findings
|
||||||
|
// is how a site gets over-trained. What is left is reported.
|
||||||
|
if progress.fixed.is_empty() || ledger.open.len() >= previous.open.len() {
|
||||||
|
eprintln!("structure round {round}: no finding closed, stopping");
|
||||||
|
log.push(format!("structure round {round}: stalled"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !green {
|
if !green {
|
||||||
std::fs::write(opts.out.join("run.log"), format!("{}\n\n{report}", log.join("\n")))?;
|
let open = if tracked {
|
||||||
bail!("structure never went green in {} round(s); last verifier output is in run.log", opts.rounds);
|
format!("\n\nStill open:\n{}", ledger.open_list(opts.rounds + 1))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
opts.out.join("run.log"),
|
||||||
|
format!("{}{open}\n\n{report}", log.join("\n")),
|
||||||
|
)?;
|
||||||
|
bail!("structure did not go green; what is still open is in run.log");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: wording.
|
// Phase 3: wording.
|
||||||
@@ -167,22 +271,48 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
let mut defects = String::new();
|
let mut defects = String::new();
|
||||||
let mut summary = Vec::new();
|
let mut summary = Vec::new();
|
||||||
for skim in [true, false] {
|
for skim in [true, false] {
|
||||||
let tasks_path = opts.out.join(if skim { "tasks-skim.jsonl" } else { "tasks.jsonl" });
|
let tasks_path = opts.out.join(if skim {
|
||||||
let mut args = vec!["--needs-tasks".to_string(), tasks_path.display().to_string()];
|
"tasks-skim.jsonl"
|
||||||
|
} else {
|
||||||
|
"tasks.jsonl"
|
||||||
|
});
|
||||||
|
let mut args = vec![
|
||||||
|
"--needs-tasks".to_string(),
|
||||||
|
tasks_path.display().to_string(),
|
||||||
|
];
|
||||||
if skim {
|
if skim {
|
||||||
args.push("--skim".to_string());
|
args.push("--skim".to_string());
|
||||||
}
|
}
|
||||||
lint(&opts, &args)?;
|
lint(&opts, &args)?;
|
||||||
let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out), 0)?;
|
let answers = answer_blind(
|
||||||
let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" });
|
opts.visitor.as_deref().unwrap_or(&opts.llm),
|
||||||
|
&std::fs::read_to_string(&tasks_path)?,
|
||||||
|
Some(&opts.out),
|
||||||
|
0,
|
||||||
|
)?;
|
||||||
|
let answers_path = opts.out.join(if skim {
|
||||||
|
"answers-skim.jsonl"
|
||||||
|
} else {
|
||||||
|
"answers.jsonl"
|
||||||
|
});
|
||||||
std::fs::write(&answers_path, answers)?;
|
std::fs::write(&answers_path, answers)?;
|
||||||
let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()];
|
let mut args = vec![
|
||||||
|
"--needs-score".to_string(),
|
||||||
|
answers_path.display().to_string(),
|
||||||
|
];
|
||||||
if skim {
|
if skim {
|
||||||
args.push("--skim".to_string());
|
args.push("--skim".to_string());
|
||||||
}
|
}
|
||||||
let (_, scored) = lint(&opts, &args)?;
|
let (_, scored) = lint(&opts, &args)?;
|
||||||
let mode = if skim { "headings only" } else { "headings and descriptions" };
|
let mode = if skim {
|
||||||
for line in scored.lines().filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) {
|
"headings only"
|
||||||
|
} else {
|
||||||
|
"headings and descriptions"
|
||||||
|
};
|
||||||
|
for line in scored
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT"))
|
||||||
|
{
|
||||||
defects.push_str(&format!("[{mode}] {line}\n"));
|
defects.push_str(&format!("[{mode}] {line}\n"));
|
||||||
}
|
}
|
||||||
if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) {
|
if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) {
|
||||||
@@ -191,7 +321,10 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
eprintln!("wording round {round}: {}", summary.join(" | "));
|
eprintln!("wording round {round}: {}", summary.join(" | "));
|
||||||
log.push(format!("wording round {round}: {}", summary.join(" | ")));
|
log.push(format!("wording round {round}: {}", summary.join(" | ")));
|
||||||
if defects.is_empty() || round == opts.wording_rounds {
|
// A visitor who chose right, hesitantly, is not a defect worth a
|
||||||
|
// rewrite on its own: only a misroute is. Hesitations ride along
|
||||||
|
// as context when there is one.
|
||||||
|
if !defects.contains("MISROUTE") || round == opts.wording_rounds {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default();
|
let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default();
|
||||||
@@ -311,6 +444,188 @@ fn lint(opts: &Opts, extra: &[String]) -> anyhow::Result<(bool, String)> {
|
|||||||
Ok((output.status.success(), report))
|
Ok((output.status.success(), report))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One finding as the lint's `--findings` writes it (docs/lint.md).
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
|
struct Finding {
|
||||||
|
id: String,
|
||||||
|
level: String,
|
||||||
|
code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
need: String,
|
||||||
|
message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
fix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lint with `--findings` into `out`, and what it found; `None` for
|
||||||
|
/// the findings when the lint predates the flag (then it is run again
|
||||||
|
/// the old way, and only its text comes back).
|
||||||
|
fn lint_findings(opts: &Opts, out: &Path) -> anyhow::Result<(bool, String, Option<Vec<Finding>>)> {
|
||||||
|
let (ok, report) = lint(opts, &["--findings".to_string(), out.display().to_string()])?;
|
||||||
|
if report.contains("unknown argument: --findings") {
|
||||||
|
let (ok, report) = lint(opts, &[])?;
|
||||||
|
return Ok((ok, report, None));
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(out).unwrap_or_default();
|
||||||
|
let found = raw
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.map(|l| {
|
||||||
|
serde_json::from_str::<Finding>(l).with_context(|| format!("reading {}", out.display()))
|
||||||
|
})
|
||||||
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
Ok((ok, report, Some(found)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every failing finding by id, across rounds.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Ledger {
|
||||||
|
open: BTreeMap<String, Finding>,
|
||||||
|
/// The round each open finding was first seen in.
|
||||||
|
since: BTreeMap<String, usize>,
|
||||||
|
/// Ids a round has closed, so one that comes back is a regression,
|
||||||
|
/// not news.
|
||||||
|
closed: std::collections::BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Progress {
|
||||||
|
fixed: Vec<Finding>,
|
||||||
|
new: Vec<Finding>,
|
||||||
|
still: usize,
|
||||||
|
reopened: Vec<Finding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Progress {
|
||||||
|
fn summary(&self, open: usize) -> String {
|
||||||
|
if open == 0 {
|
||||||
|
return format!("green (fixed {})", self.fixed.len());
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{open} open - fixed {}, still open {}, new {}, reopened {}",
|
||||||
|
self.fixed.len(),
|
||||||
|
self.still,
|
||||||
|
self.new.len(),
|
||||||
|
self.reopened.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ledger {
|
||||||
|
fn update(&mut self, round: usize, fails: Vec<Finding>) -> Progress {
|
||||||
|
let now: BTreeMap<String, Finding> = fails.into_iter().map(|f| (f.id.clone(), f)).collect();
|
||||||
|
let fixed: Vec<Finding> = self
|
||||||
|
.open
|
||||||
|
.values()
|
||||||
|
.filter(|f| !now.contains_key(&f.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let mut new = Vec::new();
|
||||||
|
let mut reopened = Vec::new();
|
||||||
|
let mut still = 0;
|
||||||
|
for (id, f) in &now {
|
||||||
|
if self.open.contains_key(id) {
|
||||||
|
still += 1;
|
||||||
|
} else if self.closed.contains(id) {
|
||||||
|
reopened.push(f.clone());
|
||||||
|
} else {
|
||||||
|
new.push(f.clone());
|
||||||
|
}
|
||||||
|
self.since.entry(id.clone()).or_insert(round);
|
||||||
|
}
|
||||||
|
for f in &fixed {
|
||||||
|
self.closed.insert(f.id.clone());
|
||||||
|
self.since.remove(&f.id);
|
||||||
|
}
|
||||||
|
self.open = now;
|
||||||
|
Progress {
|
||||||
|
fixed,
|
||||||
|
new,
|
||||||
|
still,
|
||||||
|
reopened,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open findings for a prompt or a log: id, how long open, the
|
||||||
|
/// message and its fix.
|
||||||
|
fn open_list(&self, round: usize) -> String {
|
||||||
|
self.open
|
||||||
|
.values()
|
||||||
|
.map(|f| {
|
||||||
|
let age = match self.since.get(&f.id) {
|
||||||
|
Some(&first) if first + 1 < round => format!(", open since round {first}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let scope = if f.need.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {:?}", f.need)
|
||||||
|
};
|
||||||
|
format!("- [{}] {}{scope}{age}: {}\n", f.id, f.code, f.message)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files open findings point at - every desk page their fixes name,
|
||||||
|
/// plus aggregates.yaml - when that is enough to act on. `None` when a
|
||||||
|
/// finding is about getting around the site (a path, an entry page, a
|
||||||
|
/// missing desk) or names no page: then the model needs to see it all.
|
||||||
|
fn named_files<'a>(
|
||||||
|
out: &Path,
|
||||||
|
findings: impl Iterator<Item = &'a Finding>,
|
||||||
|
) -> Option<Vec<PathBuf>> {
|
||||||
|
const WHOLE_SITE: &[&str] = &[
|
||||||
|
"schema",
|
||||||
|
"entry-missing",
|
||||||
|
"entry-gated",
|
||||||
|
"path-missing",
|
||||||
|
"path-closed",
|
||||||
|
"path-over-budget",
|
||||||
|
"desk-missing",
|
||||||
|
"need-duplicate",
|
||||||
|
];
|
||||||
|
let mut files = vec![out.join("aggregates.yaml")];
|
||||||
|
for f in findings {
|
||||||
|
if WHOLE_SITE.contains(&f.code.as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = format!("{} {}", f.message, f.fix.as_deref().unwrap_or_default());
|
||||||
|
for page in text.split_whitespace().filter(|w| w.starts_with('/')) {
|
||||||
|
let page = page.trim_end_matches(|c: char| {
|
||||||
|
!(c.is_alphanumeric() || c == '_' || c == '-' || c == '/')
|
||||||
|
});
|
||||||
|
let rel = page.trim_start_matches('/');
|
||||||
|
let candidates = if rel.is_empty() {
|
||||||
|
vec![out.join("questions/index.yaml")]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
out.join(format!("questions/{rel}.yaml")),
|
||||||
|
out.join(format!("questions/{rel}/index.yaml")),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
if let Some(path) = candidates.into_iter().find(|p| p.exists()) {
|
||||||
|
if !files.contains(&path) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `dump`, for the named files only.
|
||||||
|
fn dump_files(out: &Path, files: &[PathBuf]) -> anyhow::Result<String> {
|
||||||
|
let mut text = String::new();
|
||||||
|
for path in files.iter().filter(|p| p.exists()) {
|
||||||
|
let rel = path.strip_prefix(out).unwrap_or(path).display().to_string();
|
||||||
|
text.push_str(&format!(
|
||||||
|
"<<<FILE {rel}>>>\n{}<<<END>>>\n\n",
|
||||||
|
std::fs::read_to_string(path)?
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
|
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
|
||||||
/// ignored, so a model that adds a sentence does no harm.
|
/// ignored, so a model that adds a sentence does no harm.
|
||||||
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
||||||
@@ -363,7 +678,9 @@ fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
|||||||
/// trusted: repo-relative, yaml, and only where content lives.
|
/// trusted: repo-relative, yaml, and only where content lives.
|
||||||
fn allowed(path: &str) -> bool {
|
fn allowed(path: &str) -> bool {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
let plain = p.components().all(|c| matches!(c, std::path::Component::Normal(_)));
|
let plain = p
|
||||||
|
.components()
|
||||||
|
.all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||||
plain
|
plain
|
||||||
&& p.extension().and_then(|e| e.to_str()) == Some("yaml")
|
&& p.extension().and_then(|e| e.to_str()) == Some("yaml")
|
||||||
&& (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/"))
|
&& (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/"))
|
||||||
@@ -434,8 +751,15 @@ fn restore(out: &Path, before: Vec<(PathBuf, String)>) -> anyhow::Result<()> {
|
|||||||
fn dump(out: &Path) -> anyhow::Result<String> {
|
fn dump(out: &Path) -> anyhow::Result<String> {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
for path in content_files(out)? {
|
for path in content_files(out)? {
|
||||||
let rel = path.strip_prefix(out).unwrap_or(&path).display().to_string();
|
let rel = path
|
||||||
text.push_str(&format!("<<<FILE {rel}>>>\n{}<<<END>>>\n\n", std::fs::read_to_string(&path)?));
|
.strip_prefix(out)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
text.push_str(&format!(
|
||||||
|
"<<<FILE {rel}>>>\n{}<<<END>>>\n\n",
|
||||||
|
std::fs::read_to_string(&path)?
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
@@ -453,7 +777,12 @@ fn dump(out: &Path) -> anyhow::Result<String> {
|
|||||||
/// card portal renders: button, encouragement, sections with icon and
|
/// card portal renders: button, encouragement, sections with icon and
|
||||||
/// guidance, fields with placeholders. Headings-only tasks (`--skim`,
|
/// guidance, fields with placeholders. Headings-only tasks (`--skim`,
|
||||||
/// where the task's text is the heading itself) stay headings only.
|
/// where the task's text is the heading itself) stay headings only.
|
||||||
pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_offset: u64) -> anyhow::Result<String> {
|
pub(crate) fn answer_blind(
|
||||||
|
cmd: &str,
|
||||||
|
tasks: &str,
|
||||||
|
repo: Option<&Path>,
|
||||||
|
seed_offset: u64,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
let mut answers = String::new();
|
let mut answers = String::new();
|
||||||
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
|
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
|
||||||
let task: serde_json::Value = serde_json::from_str(line)?;
|
let task: serde_json::Value = serde_json::from_str(line)?;
|
||||||
@@ -467,8 +796,14 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
|||||||
.flatten()
|
.flatten()
|
||||||
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
|
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
|
||||||
.collect();
|
.collect();
|
||||||
let skim = options.iter().all(|(name, text)| text.is_empty() || text == name);
|
let skim = options
|
||||||
let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) };
|
.iter()
|
||||||
|
.all(|(name, text)| text.is_empty() || text == name);
|
||||||
|
let page = if skim {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/")))
|
||||||
|
};
|
||||||
let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _);
|
let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _);
|
||||||
shuffle(&mut options, seed);
|
shuffle(&mut options, seed);
|
||||||
// Options are numbered and the answer is the number: with a
|
// Options are numbered and the answer is the number: with a
|
||||||
@@ -496,14 +831,25 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
|||||||
let mut head = page.as_ref().map(card::page_head).unwrap_or_default();
|
let mut head = page.as_ref().map(card::page_head).unwrap_or_default();
|
||||||
// What the page says before it offers anything (the opener, the
|
// What the page says before it offers anything (the opener, the
|
||||||
// look around): read, not picked.
|
// look around): read, not picked.
|
||||||
if let Some(context) = task["state"]["page"].as_str().filter(|c| !c.trim().is_empty()) {
|
if let Some(context) = task["state"]["page"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|c| !c.trim().is_empty())
|
||||||
|
{
|
||||||
head.push_str(&format!("The page first says:\n{context}\n\n"));
|
head.push_str(&format!("The page first says:\n{context}\n\n"));
|
||||||
}
|
}
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": <the option's number>, \"confidence\": 0.00}}",
|
"You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": <the option's number>, \"confidence\": 0.00}}",
|
||||||
listed.join("\n\n")
|
listed.join("\n\n")
|
||||||
);
|
);
|
||||||
let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?;
|
let reply = llm_env(
|
||||||
|
cmd,
|
||||||
|
&prompt,
|
||||||
|
&[
|
||||||
|
("FORMAT", "json"),
|
||||||
|
("SEED", &seed.to_string()),
|
||||||
|
("TEMPERATURE", "0.2"),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null);
|
let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null);
|
||||||
let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
|
let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
|
||||||
let raw = match &parsed["choice"] {
|
let raw = match &parsed["choice"] {
|
||||||
@@ -516,7 +862,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
|||||||
_ => simulate::resolve_choice(&names, &raw),
|
_ => simulate::resolve_choice(&names, &raw),
|
||||||
};
|
};
|
||||||
let confidence = parsed["confidence"].as_f64().unwrap_or(0.0);
|
let confidence = parsed["confidence"].as_f64().unwrap_or(0.0);
|
||||||
answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?);
|
answers.push_str(&serde_json::to_string(
|
||||||
|
&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }),
|
||||||
|
)?);
|
||||||
answers.push('\n');
|
answers.push('\n');
|
||||||
}
|
}
|
||||||
Ok(answers)
|
Ok(answers)
|
||||||
@@ -524,7 +872,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
|||||||
|
|
||||||
/// FNV-1a over the task id: a seed that is the same on every run.
|
/// FNV-1a over the task id: a seed that is the same on every run.
|
||||||
fn fnv(text: &str) -> u32 {
|
fn fnv(text: &str) -> u32 {
|
||||||
text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193))
|
text.bytes().fold(0x811c_9dc5u32, |h, b| {
|
||||||
|
(h ^ u32::from(b)).wrapping_mul(0x0100_0193)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fisher-Yates on a small xorshift, so the order is fixed by the seed.
|
/// Fisher-Yates on a small xorshift, so the order is fixed by the seed.
|
||||||
@@ -591,3 +941,79 @@ mod tests {
|
|||||||
assert!(!allowed(".gitea/workflows/deploy.yaml"));
|
assert!(!allowed(".gitea/workflows/deploy.yaml"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ledger_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn f(id: &str, code: &str, message: &str) -> Finding {
|
||||||
|
Finding {
|
||||||
|
id: id.into(),
|
||||||
|
level: "fail".into(),
|
||||||
|
code: code.into(),
|
||||||
|
need: "hire".into(),
|
||||||
|
message: message.into(),
|
||||||
|
fix: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_round_is_told_what_it_closed_kept_added_and_broke() {
|
||||||
|
let mut ledger = Ledger::default();
|
||||||
|
let p = ledger.update(1, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
|
||||||
|
assert_eq!(
|
||||||
|
(p.fixed.len(), p.new.len(), p.still, p.reopened.len()),
|
||||||
|
(0, 2, 0, 0)
|
||||||
|
);
|
||||||
|
let p = ledger.update(
|
||||||
|
2,
|
||||||
|
vec![f("b", "strand", "y"), f("c", "edge-unoffered", "z")],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(p.fixed.len(), p.new.len(), p.still, p.reopened.len()),
|
||||||
|
(1, 1, 1, 0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.summary(ledger.open.len()),
|
||||||
|
"2 open - fixed 1, still open 1, new 1, reopened 0"
|
||||||
|
);
|
||||||
|
// "a" was closed in round 2; its return is a regression.
|
||||||
|
let p = ledger.update(3, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
|
||||||
|
assert_eq!(
|
||||||
|
p.reopened.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
["a"]
|
||||||
|
);
|
||||||
|
assert!(ledger
|
||||||
|
.open_list(4)
|
||||||
|
.contains("[b] strand \"hire\", open since round 1: y"));
|
||||||
|
let p = ledger.update(4, vec![]);
|
||||||
|
assert_eq!(p.summary(ledger.open.len()), "green (fixed 2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fix_round_sees_the_pages_its_findings_name() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ledger-test-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(dir.join("questions/desk")).unwrap();
|
||||||
|
std::fs::write(dir.join("aggregates.yaml"), "x").unwrap();
|
||||||
|
std::fs::write(dir.join("questions/desk/index.yaml"), "x").unwrap();
|
||||||
|
std::fs::write(dir.join("questions/index.yaml"), "x").unwrap();
|
||||||
|
let mut strand = f("a", "strand", "records strand in \"quoted\"");
|
||||||
|
strand.fix =
|
||||||
|
Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk".into());
|
||||||
|
let files = named_files(&dir, [&strand].into_iter()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
files,
|
||||||
|
vec![
|
||||||
|
dir.join("aggregates.yaml"),
|
||||||
|
dir.join("questions/desk/index.yaml")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// A finding about getting around the site needs all of it.
|
||||||
|
assert!(named_files(
|
||||||
|
&dir,
|
||||||
|
[&strand, &f("b", "path-missing", "no path")].into_iter()
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-12
@@ -284,7 +284,11 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
|
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
|
||||||
let doc: serde_yaml::Value =
|
let doc: serde_yaml::Value =
|
||||||
serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?;
|
serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?;
|
||||||
Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?)
|
Some(
|
||||||
|
doc.get("personas")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("{path} has no `personas:`"))?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
@@ -323,13 +327,14 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
if let Some(list) = personas.as_mut() {
|
if let Some(list) = personas.as_mut() {
|
||||||
let mapped = map_personas(&cmd, &plan, list)?;
|
let mapped = map_personas(&cmd, &plan, list)?;
|
||||||
if mapped > 0 {
|
if mapped > 0 {
|
||||||
eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs");
|
eprintln!(
|
||||||
|
"plan round {round}: {mapped} persona(s) mapped to this plan's needs"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
text.push_str("\n# Held out from every model that writes words.\n");
|
text.push_str("\n# Held out from every model that writes words.\n");
|
||||||
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([(
|
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([
|
||||||
serde_yaml::Value::from("personas"),
|
(serde_yaml::Value::from("personas"), list.clone()),
|
||||||
list.clone(),
|
]))?);
|
||||||
)]))?);
|
|
||||||
}
|
}
|
||||||
let whole: Plan =
|
let whole: Plan =
|
||||||
serde_yaml::from_str(&text).context("the plan with its personas attached")?;
|
serde_yaml::from_str(&text).context("the plan with its personas attached")?;
|
||||||
@@ -411,25 +416,42 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
/// not have, or a persona the model leaves out, stays as it was.
|
/// not have, or a persona the model leaves out, stays as it was.
|
||||||
fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result<usize> {
|
fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result<usize> {
|
||||||
let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect();
|
let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect();
|
||||||
let Some(list) = personas.as_sequence_mut() else { return Ok(0) };
|
let Some(list) = personas.as_sequence_mut() else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
let strangers: Vec<(String, String)> = list
|
let strangers: Vec<(String, String)> = list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default()))
|
.filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default()))
|
||||||
.map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string()))
|
.map(|p| {
|
||||||
|
(
|
||||||
|
p["id"].as_str().unwrap_or_default().to_string(),
|
||||||
|
p["text"].as_str().unwrap_or_default().to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.filter(|(id, _)| !id.is_empty())
|
.filter(|(id, _)| !id.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if strangers.is_empty() {
|
if strangers.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let needs: Vec<String> = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect();
|
let needs: Vec<String> = plan
|
||||||
let people: Vec<String> = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect();
|
.needs
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants))
|
||||||
|
.collect();
|
||||||
|
let people: Vec<String> = strangers
|
||||||
|
.iter()
|
||||||
|
.map(|(id, text)| format!("- {id}: {text}"))
|
||||||
|
.collect();
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.",
|
"A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.",
|
||||||
needs.join("\n"),
|
needs.join("\n"),
|
||||||
people.join("\n")
|
people.join("\n")
|
||||||
);
|
);
|
||||||
let reply = crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
|
let reply =
|
||||||
let Some(mapping) = first_object(&reply) else { return Ok(0) };
|
crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
|
||||||
|
let Some(mapping) = first_object(&reply) else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
let mut mapped = 0;
|
let mut mapped = 0;
|
||||||
for persona in list.iter_mut() {
|
for persona in list.iter_mut() {
|
||||||
let id = persona["id"].as_str().unwrap_or_default().to_string();
|
let id = persona["id"].as_str().unwrap_or_default().to_string();
|
||||||
|
|||||||
+367
-75
@@ -34,11 +34,16 @@ use crate::trainer::card;
|
|||||||
use crate::trainer::simulate::ask;
|
use crate::trainer::simulate::ask;
|
||||||
|
|
||||||
fn flag(argv: &[String], name: &str) -> Option<String> {
|
fn flag(argv: &[String], name: &str) -> Option<String> {
|
||||||
argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned()
|
argv.iter()
|
||||||
|
.position(|a| a == name)
|
||||||
|
.and_then(|i| argv.get(i + 1))
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(path: &str) -> anyhow::Result<Value> {
|
fn load(path: &str) -> anyhow::Result<Value> {
|
||||||
Ok(serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)?)
|
Ok(serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LIX = words per sentence + 100 * long words / words.
|
/// LIX = words per sentence + 100 * long words / words.
|
||||||
@@ -50,7 +55,11 @@ pub fn lix(text: &str) -> f64 {
|
|||||||
if words.is_empty() {
|
if words.is_empty() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let sentences = text.split(['.', '!', '?', ':', '\n']).filter(|s| s.chars().any(char::is_alphabetic)).count().max(1);
|
let sentences = text
|
||||||
|
.split(['.', '!', '?', ':', '\n'])
|
||||||
|
.filter(|s| s.chars().any(char::is_alphabetic))
|
||||||
|
.count()
|
||||||
|
.max(1);
|
||||||
let long = words.iter().filter(|w| w.chars().count() > 6).count();
|
let long = words.iter().filter(|w| w.chars().count() > 6).count();
|
||||||
words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64
|
words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64
|
||||||
}
|
}
|
||||||
@@ -74,7 +83,10 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
|
|||||||
};
|
};
|
||||||
// The cards that lead nowhere (the opener, the look around) are
|
// The cards that lead nowhere (the opener, the look around) are
|
||||||
// read before the options; the gutted twin loses them too.
|
// read before the options; the gutted twin loses them too.
|
||||||
if let Some(context) = step["context"].as_str().filter(|c| !c.trim().is_empty() && !gutted) {
|
if let Some(context) = step["context"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|c| !c.trim().is_empty() && !gutted)
|
||||||
|
{
|
||||||
text.push_str(&format!("\n{context}\n"));
|
text.push_str(&format!("\n{context}\n"));
|
||||||
}
|
}
|
||||||
for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() {
|
for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() {
|
||||||
@@ -105,7 +117,12 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
|
|||||||
// tells a reader how many things they will be asked, and a model
|
// tells a reader how many things they will be asked, and a model
|
||||||
// answers "can you tell what you will be asked" with yes to that
|
// answers "can you tell what you will be asked" with yes to that
|
||||||
// (measured: 2 of 2 on every need).
|
// (measured: 2 of 2 on every need).
|
||||||
let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect();
|
let fields: Vec<&str> = step["fields"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|f| f["label"].as_str())
|
||||||
|
.collect();
|
||||||
if !gutted && !fields.is_empty() {
|
if !gutted && !fields.is_empty() {
|
||||||
text.push_str(&format!(
|
text.push_str(&format!(
|
||||||
"\nThe form under \"{}\" asks for: {}.\n",
|
"\nThe form under \"{}\" asks for: {}.\n",
|
||||||
@@ -121,13 +138,24 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
|
|||||||
|
|
||||||
fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
|
fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
|
||||||
if let Some(next) = followup {
|
if let Some(next) = followup {
|
||||||
text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default()));
|
text.push_str(&format!(
|
||||||
if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) {
|
"\nAfter you send it, the next page says:\n{}\n",
|
||||||
|
next["name"].as_str().unwrap_or_default()
|
||||||
|
));
|
||||||
|
if let Some(d) = next["description"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|d| !d.trim().is_empty())
|
||||||
|
{
|
||||||
text.push_str(&format!("{}\n", d.trim()));
|
text.push_str(&format!("{}\n", d.trim()));
|
||||||
}
|
}
|
||||||
for alt in next["alternatives"].as_array().into_iter().flatten() {
|
for alt in next["alternatives"].as_array().into_iter().flatten() {
|
||||||
let d = alt["description"].as_str().unwrap_or_default().trim();
|
let d = alt["description"].as_str().unwrap_or_default().trim();
|
||||||
text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d));
|
text.push_str(&format!(
|
||||||
|
"- {}{}{}\n",
|
||||||
|
alt["name"].as_str().unwrap_or_default(),
|
||||||
|
if d.is_empty() { "" } else { ": " },
|
||||||
|
d
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
@@ -137,12 +165,30 @@ fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
|
|||||||
/// description, and the followup. Headings and labels are left out.
|
/// description, and the followup. Headings and labels are left out.
|
||||||
fn prose_text(step: &Value, followup: Option<&Value>) -> String {
|
fn prose_text(step: &Value, followup: Option<&Value>) -> String {
|
||||||
let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()];
|
let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()];
|
||||||
prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string));
|
prose.extend(
|
||||||
|
step["options"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|o| o["description"].as_str())
|
||||||
|
.map(str::to_string),
|
||||||
|
);
|
||||||
if let Some(next) = followup {
|
if let Some(next) = followup {
|
||||||
prose.extend(next["description"].as_str().map(str::to_string));
|
prose.extend(next["description"].as_str().map(str::to_string));
|
||||||
prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string));
|
prose.extend(
|
||||||
|
next["alternatives"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|a| a["description"].as_str())
|
||||||
|
.map(str::to_string),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
prose.into_iter().filter(|p| !p.trim().is_empty()).collect::<Vec<_>>().join("\n")
|
prose
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| !p.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The page a visitor lands on after the step's alternative: read from
|
/// The page a visitor lands on after the step's alternative: read from
|
||||||
@@ -152,7 +198,11 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
|
|||||||
let repo = repo?;
|
let repo = repo?;
|
||||||
let page = load_page(repo, step["page"].as_str()?)?;
|
let page = load_page(repo, step["page"].as_str()?)?;
|
||||||
let alternative = step["alternative"].as_str()?;
|
let alternative = step["alternative"].as_str()?;
|
||||||
let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?;
|
let action = page["alternatives"]
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["name"] == alternative)?["action"]
|
||||||
|
.as_str()?;
|
||||||
load_page(repo, action)
|
load_page(repo, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,15 +237,33 @@ fn s(v: &Value) -> String {
|
|||||||
/// the button for it. The outcome is put in plain words from the
|
/// the button for it. The outcome is put in plain words from the
|
||||||
/// state's own name, never from the label, and the buttons are listed
|
/// state's own name, never from the label, and the buttons are listed
|
||||||
/// in a seeded shuffle.
|
/// in a seeded shuffle.
|
||||||
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result<String> {
|
fn button_pick(
|
||||||
|
cmd: &str,
|
||||||
|
group: &str,
|
||||||
|
bucket: &str,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
labels: &[&str],
|
||||||
|
seed: usize,
|
||||||
|
words: &Value,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
// The state in the plan's own words when the spec carries them,
|
// The state in the plan's own words when the spec carries them,
|
||||||
// else the id made readable.
|
// else the id made readable.
|
||||||
let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " "));
|
let plain = |id: &str| {
|
||||||
|
words[id]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| id.replace(['_', '-'], " "))
|
||||||
|
};
|
||||||
let mut order: Vec<&str> = labels.to_vec();
|
let mut order: Vec<&str> = labels.to_vec();
|
||||||
// A fixed rotation per move: the right answer is not always first.
|
// A fixed rotation per move: the right answer is not always first.
|
||||||
let by = seed % order.len().max(1);
|
let by = seed % order.len().max(1);
|
||||||
order.rotate_left(by);
|
order.rotate_left(by);
|
||||||
let listed: Vec<String> = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect();
|
let listed: Vec<String> = order
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| format!("{}. {l}", i + 1))
|
||||||
|
.collect();
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": <the button's number>}}",
|
"You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": <the button's number>}}",
|
||||||
plain(group),
|
plain(group),
|
||||||
@@ -206,7 +274,10 @@ fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, label
|
|||||||
);
|
);
|
||||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||||
let n = reply["choice"].as_u64().unwrap_or(0) as usize;
|
let n = reply["choice"].as_u64().unwrap_or(0) as usize;
|
||||||
Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default())
|
Ok(order
|
||||||
|
.get(n.wrapping_sub(1))
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every `mail:` on a state in the repo's aggregates.yaml, as
|
/// Every `mail:` on a state in the repo's aggregates.yaml, as
|
||||||
@@ -228,7 +299,12 @@ pub(crate) fn mail_texts(repo: &Path) -> Option<Vec<(String, String, String)>> {
|
|||||||
}
|
}
|
||||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
};
|
};
|
||||||
Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect())
|
Some(
|
||||||
|
raw_mails(repo, &doc)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, subject, body)| (name, strip(&subject), strip(&body)))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every mail the site sends, as written: the state mails in
|
/// Every mail the site sends, as written: the state mails in
|
||||||
@@ -248,10 +324,17 @@ fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
let site: Value = std::fs::read_to_string(repo.join("site.yaml"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| serde_yaml::from_str(&t).ok())
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
let invite = &site["mail"]["invite"];
|
let invite = &site["mail"]["invite"];
|
||||||
if invite.is_object() {
|
if invite.is_object() {
|
||||||
out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string()));
|
out.push((
|
||||||
|
"invite".to_string(),
|
||||||
|
invite["subject"].as_str().unwrap_or_default().to_string(),
|
||||||
|
invite["body"].as_str().unwrap_or_default().to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -265,7 +348,12 @@ fn as_received(template: &str, site_name: &str) -> String {
|
|||||||
out.push_str(&rest[..i]);
|
out.push_str(&rest[..i]);
|
||||||
let after = &rest[i + 1..];
|
let after = &rest[i + 1..];
|
||||||
match after.find('}') {
|
match after.find('}') {
|
||||||
Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => {
|
Some(j)
|
||||||
|
if after[..j]
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
&& j > 0 =>
|
||||||
|
{
|
||||||
out.push_str(&match &after[..j] {
|
out.push_str(&match &after[..j] {
|
||||||
"site" => "https://example.site".to_string(),
|
"site" => "https://example.site".to_string(),
|
||||||
"chain" => "8f3a1c".to_string(),
|
"chain" => "8f3a1c".to_string(),
|
||||||
@@ -287,19 +375,38 @@ fn as_received(template: &str, site_name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAIL_QUESTIONS: [(&str, &str); 3] = [
|
const MAIL_QUESTIONS: [(&str, &str); 3] = [
|
||||||
("happened", "Do you know, from this mail alone, what has happened and to what?"),
|
(
|
||||||
("now", "Do you know what, if anything, you can or must do now, and how?"),
|
"happened",
|
||||||
("who", "Do you know who sent it, and how you would answer them?"),
|
"Do you know, from this mail alone, what has happened and to what?",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"now",
|
||||||
|
"Do you know what, if anything, you can or must do now, and how?",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"who",
|
||||||
|
"Do you know who sent it, and how you would answer them?",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Three yes-or-no questions on one mail, from the person it is for.
|
/// Three yes-or-no questions on one mail, from the person it is for.
|
||||||
fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> {
|
fn mail_understanding(
|
||||||
|
cmd: &str,
|
||||||
|
reader: &str,
|
||||||
|
subject: &str,
|
||||||
|
body: &str,
|
||||||
|
seed: usize,
|
||||||
|
) -> anyhow::Result<[bool; 3]> {
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}",
|
"You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}",
|
||||||
MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
||||||
);
|
);
|
||||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||||
Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true])
|
Ok([
|
||||||
|
reply["happened"] == true,
|
||||||
|
reply["now"] == true,
|
||||||
|
reply["who"] == true,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or
|
/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or
|
||||||
@@ -309,9 +416,14 @@ pub(crate) fn load_page(repo: &Path, route: &str) -> Option<Value> {
|
|||||||
let candidates = if rel.is_empty() {
|
let candidates = if rel.is_empty() {
|
||||||
vec![repo.join("questions/index.yaml")]
|
vec![repo.join("questions/index.yaml")]
|
||||||
} else {
|
} else {
|
||||||
vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))]
|
vec![
|
||||||
|
repo.join(format!("questions/{rel}.yaml")),
|
||||||
|
repo.join(format!("questions/{rel}/index.yaml")),
|
||||||
|
]
|
||||||
};
|
};
|
||||||
let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?;
|
let text = candidates
|
||||||
|
.iter()
|
||||||
|
.find_map(|p| std::fs::read_to_string(p).ok())?;
|
||||||
serde_yaml::from_str(&text).ok()
|
serde_yaml::from_str(&text).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +438,12 @@ const MAX_REQUIRED: usize = 5;
|
|||||||
fn page_load(step: &Value) -> (usize, usize) {
|
fn page_load(step: &Value) -> (usize, usize) {
|
||||||
(
|
(
|
||||||
step["options"].as_array().map_or(0, Vec::len),
|
step["options"].as_array().map_or(0, Vec::len),
|
||||||
step["fields"].as_array().into_iter().flatten().filter(|f| f["optional"] != true).count(),
|
step["fields"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|f| f["optional"] != true)
|
||||||
|
.count(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +461,12 @@ fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Resul
|
|||||||
QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
||||||
);
|
);
|
||||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||||
Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true])
|
Ok([
|
||||||
|
reply["which"] == true,
|
||||||
|
reply["where"] == true,
|
||||||
|
reply["asked"] == true,
|
||||||
|
reply["next"] == true,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||||
@@ -365,7 +487,10 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
// that desk's buttons for the bucket.
|
// that desk's buttons for the bucket.
|
||||||
let desks = desk_buttons(&model);
|
let desks = desk_buttons(&model);
|
||||||
if !desks.is_empty() {
|
if !desks.is_empty() {
|
||||||
println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for");
|
println!(
|
||||||
|
"{:<20} {:<24} {:>8} {}",
|
||||||
|
"desk", "bucket", "buttons", "mistaken for"
|
||||||
|
);
|
||||||
for (page, group, bucket, moves) in &desks {
|
for (page, group, bucket, moves) in &desks {
|
||||||
// A desk row shows only the buttons that start from the
|
// A desk row shows only the buttons that start from the
|
||||||
// row's state, so those are the ones the worker chooses
|
// row's state, so those are the ones the worker chooses
|
||||||
@@ -374,12 +499,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
let mut asked = 0;
|
let mut asked = 0;
|
||||||
let mut mistaken = Vec::new();
|
let mut mistaken = Vec::new();
|
||||||
for (i, (from, to, label)) in moves.iter().enumerate() {
|
for (i, (from, to, label)) in moves.iter().enumerate() {
|
||||||
let labels: Vec<&str> = moves.iter().filter(|(f, _, _)| f == from).map(|(_, _, l)| l.as_str()).collect();
|
let labels: Vec<&str> = moves
|
||||||
|
.iter()
|
||||||
|
.filter(|(f, _, _)| f == from)
|
||||||
|
.map(|(_, _, l)| l.as_str())
|
||||||
|
.collect();
|
||||||
if labels.len() < 2 {
|
if labels.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
asked += 1;
|
asked += 1;
|
||||||
let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?;
|
let picked = button_pick(
|
||||||
|
&cmd,
|
||||||
|
group,
|
||||||
|
bucket,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
&labels,
|
||||||
|
i,
|
||||||
|
&model["buckets"][bucket]["words"],
|
||||||
|
)?;
|
||||||
if picked == *label {
|
if picked == *label {
|
||||||
right += 1;
|
right += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -389,8 +527,18 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
if asked == 0 {
|
if asked == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, asked, mistaken.join("; "));
|
println!(
|
||||||
report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": asked, "mistaken": mistaken }));
|
"{:<20} {:<24} {:>3} of {:<2} {}",
|
||||||
|
page,
|
||||||
|
bucket,
|
||||||
|
right,
|
||||||
|
asked,
|
||||||
|
mistaken.join("; ")
|
||||||
|
);
|
||||||
|
report.insert(
|
||||||
|
format!("desk {page} {bucket}"),
|
||||||
|
json!({ "right": right, "of": asked, "mistaken": mistaken }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
@@ -404,12 +552,23 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
if let Some(mails) = repo.as_deref().and_then(mail_texts) {
|
if let Some(mails) = repo.as_deref().and_then(mail_texts) {
|
||||||
if !mails.is_empty() {
|
if !mails.is_empty() {
|
||||||
let dir = repo.as_deref().unwrap_or(Path::new("."));
|
let dir = repo.as_deref().unwrap_or(Path::new("."));
|
||||||
let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
let site: Value = std::fs::read_to_string(dir.join("site.yaml"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| serde_yaml::from_str(&t).ok())
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
let site_name = site["title"].as_str().unwrap_or("the site").to_string();
|
let site_name = site["title"].as_str().unwrap_or("the site").to_string();
|
||||||
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| serde_yaml::from_str(&t).ok())
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
let raw = raw_mails(dir, &aggregates);
|
let raw = raw_mails(dir, &aggregates);
|
||||||
println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says");
|
println!(
|
||||||
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() {
|
"{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
|
||||||
|
"mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says"
|
||||||
|
);
|
||||||
|
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in
|
||||||
|
mails.iter().zip(&raw).enumerate()
|
||||||
|
{
|
||||||
let text = format!("{subject}. {body}");
|
let text = format!("{subject}. {body}");
|
||||||
let score = lix(&text);
|
let score = lix(&text);
|
||||||
let reader = if name == "invite" {
|
let reader = if name == "invite" {
|
||||||
@@ -417,30 +576,59 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
format!("someone who sent a form to {site_name} some time ago")
|
format!("someone who sent a form to {site_name} some time ago")
|
||||||
};
|
};
|
||||||
let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name));
|
let (subject_in, body_in) = (
|
||||||
|
as_received(raw_subject, &site_name),
|
||||||
|
as_received(raw_body, &site_name),
|
||||||
|
);
|
||||||
let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?;
|
let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?;
|
||||||
let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?;
|
let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?;
|
||||||
let yn = |b: bool| if b { "yes" } else { "no" };
|
let yn = |b: bool| if b { "yes" } else { "no" };
|
||||||
let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect();
|
let failed: Vec<&str> = MAIL_QUESTIONS
|
||||||
|
.iter()
|
||||||
|
.zip(twin)
|
||||||
|
.filter(|(_, t)| *t)
|
||||||
|
.map(|((k, _), _)| *k)
|
||||||
|
.collect();
|
||||||
println!(
|
println!(
|
||||||
"{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
|
"{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
|
||||||
name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]),
|
name,
|
||||||
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
|
score,
|
||||||
|
lix_band(score),
|
||||||
|
text.split_whitespace().count(),
|
||||||
|
yn(got[0]),
|
||||||
|
yn(got[1]),
|
||||||
|
yn(got[2]),
|
||||||
|
if failed.is_empty() {
|
||||||
|
"ok".to_string()
|
||||||
|
} else {
|
||||||
|
format!("FAILED: {}", failed.join(", "))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
report.insert(
|
||||||
|
format!("mail {name}"),
|
||||||
|
json!({
|
||||||
|
"lix": score, "words": text.split_whitespace().count(),
|
||||||
|
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
|
||||||
|
"control_ok": failed.is_empty(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
report.insert(format!("mail {name}"), json!({
|
|
||||||
"lix": score, "words": text.split_whitespace().count(),
|
|
||||||
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
|
|
||||||
"control_ok": failed.is_empty(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load");
|
println!(
|
||||||
|
"{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
|
||||||
|
"need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load"
|
||||||
|
);
|
||||||
for need in model["needs"].as_array().into_iter().flatten() {
|
for need in model["needs"].as_array().into_iter().flatten() {
|
||||||
let id = need["id"].as_str().unwrap_or_default();
|
let id = need["id"].as_str().unwrap_or_default();
|
||||||
let steps = need["steps"].as_array().cloned().unwrap_or_default();
|
let steps = need["steps"].as_array().cloned().unwrap_or_default();
|
||||||
let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect();
|
let personas: Vec<&Value> = model["personas"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|p| p["need"] == id)
|
||||||
|
.collect();
|
||||||
if personas.is_empty() {
|
if personas.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -450,15 +638,30 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
// after sending.
|
// after sending.
|
||||||
let mut pages = Vec::new();
|
let mut pages = Vec::new();
|
||||||
for (i, step) in steps.iter().enumerate() {
|
for (i, step) in steps.iter().enumerate() {
|
||||||
let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None };
|
let followup = if i + 1 == steps.len() {
|
||||||
let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
|
followup_page(repo.as_deref(), step)
|
||||||
let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() };
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let page = repo
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
|
||||||
|
let row = if steps.len() > 1 {
|
||||||
|
format!("{id} @{}", step["page"].as_str().unwrap_or_default())
|
||||||
|
} else {
|
||||||
|
id.to_string()
|
||||||
|
};
|
||||||
let readability = lix(&prose_text(step, followup.as_ref()));
|
let readability = lix(&prose_text(step, followup.as_ref()));
|
||||||
let (mut yes, mut control) = ([0usize; N], [0usize; N]);
|
let (mut yes, mut control) = ([0usize; N], [0usize; N]);
|
||||||
for (i, persona) in personas.iter().enumerate() {
|
for (i, persona) in personas.iter().enumerate() {
|
||||||
let who = persona["text"].as_str().unwrap_or_default();
|
let who = persona["text"].as_str().unwrap_or_default();
|
||||||
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
|
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
|
||||||
let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?;
|
let answers = understanding(
|
||||||
|
&cmd,
|
||||||
|
who,
|
||||||
|
&page_text(step, page.as_ref(), followup.as_ref(), gutted),
|
||||||
|
i,
|
||||||
|
)?;
|
||||||
for (slot, answer) in tally.iter_mut().zip(answers) {
|
for (slot, answer) in tally.iter_mut().zip(answers) {
|
||||||
*slot += answer as usize;
|
*slot += answer as usize;
|
||||||
}
|
}
|
||||||
@@ -466,12 +669,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
let n = personas.len();
|
let n = personas.len();
|
||||||
let measured = |k: usize| k != 3 || followup.is_some();
|
let measured = |k: usize| k != 3 || followup.is_some();
|
||||||
let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() };
|
let share = |k: usize| {
|
||||||
|
if measured(k) {
|
||||||
|
format!("{}/{n}", yes[k])
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
// The twin has no headings, no descriptions, no cards and no
|
// The twin has no headings, no descriptions, no cards and no
|
||||||
// followup: every question must collapse on it, or that
|
// followup: every question must collapse on it, or that
|
||||||
// question is agreeing with anything.
|
// question is agreeing with anything.
|
||||||
let control_ok: Vec<bool> = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect();
|
let control_ok: Vec<bool> = (0..N)
|
||||||
let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect();
|
.map(|k| !measured(k) || control[k] * 2 < n.max(1))
|
||||||
|
.collect();
|
||||||
|
let failed: Vec<&str> = QUESTIONS
|
||||||
|
.iter()
|
||||||
|
.zip(&control_ok)
|
||||||
|
.filter(|(_, ok)| !**ok)
|
||||||
|
.map(|((k, _), _)| *k)
|
||||||
|
.collect();
|
||||||
let (options, required) = page_load(step);
|
let (options, required) = page_load(step);
|
||||||
println!(
|
println!(
|
||||||
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
|
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
|
||||||
@@ -487,12 +703,24 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
control[0],
|
control[0],
|
||||||
control[1],
|
control[1],
|
||||||
control[2],
|
control[2],
|
||||||
if measured(3) { control[3].to_string() } else { "-".to_string() },
|
if measured(3) {
|
||||||
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
|
control[3].to_string()
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
},
|
||||||
|
if failed.is_empty() {
|
||||||
|
"ok".to_string()
|
||||||
|
} else {
|
||||||
|
format!("FAILED: {}", failed.join(", "))
|
||||||
|
}
|
||||||
),
|
),
|
||||||
format!(
|
format!(
|
||||||
"{options} options, {required} required{}",
|
"{options} options, {required} required{}",
|
||||||
if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" }
|
if options > MAX_OPTIONS || required > MAX_REQUIRED {
|
||||||
|
" - SPLIT"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
pages.push(json!({
|
pages.push(json!({
|
||||||
@@ -505,9 +733,15 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
"control_ok": failed.is_empty(),
|
"control_ok": failed.is_empty(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages }));
|
report.insert(
|
||||||
|
id.to_string(),
|
||||||
|
json!({ "steps": pages.len(), "pages": pages }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?;
|
std::fs::write(
|
||||||
|
out.join("rate.json"),
|
||||||
|
serde_json::to_string_pretty(&report)?,
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,22 +751,49 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?;
|
let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?;
|
||||||
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
|
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
|
||||||
|
|
||||||
println!("{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B");
|
println!(
|
||||||
|
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
|
||||||
|
"need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B"
|
||||||
|
);
|
||||||
println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer");
|
println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer");
|
||||||
for need_a in a["needs"].as_array().into_iter().flatten() {
|
for need_a in a["needs"].as_array().into_iter().flatten() {
|
||||||
let id = need_a["id"].as_str().unwrap_or_default();
|
let id = need_a["id"].as_str().unwrap_or_default();
|
||||||
let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue };
|
let Some(need_b) = b["needs"]
|
||||||
let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue };
|
.as_array()
|
||||||
let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false));
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find(|n| n["id"] == id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (Some(step_a), Some(step_b)) = (
|
||||||
|
need_a["steps"].as_array().and_then(|s| s.first()),
|
||||||
|
need_b["steps"].as_array().and_then(|s| s.first()),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (text_a, text_b) = (
|
||||||
|
page_text(step_a, None, None, false),
|
||||||
|
page_text(step_b, None, None, false),
|
||||||
|
);
|
||||||
if text_a == text_b {
|
if text_a == text_b {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// [friendlier, clearer] x [A, B, none]
|
// [friendlier, clearer] x [A, B, none]
|
||||||
let mut wins = [[0usize; 3]; 2];
|
let mut wins = [[0usize; 3]; 2];
|
||||||
for (i, persona) in a["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).enumerate() {
|
for (i, persona) in a["personas"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|p| p["need"] == id)
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
let who = persona["text"].as_str().unwrap_or_default();
|
let who = persona["text"].as_str().unwrap_or_default();
|
||||||
let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]];
|
let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]];
|
||||||
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)].into_iter().enumerate() {
|
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}"
|
"You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}"
|
||||||
);
|
);
|
||||||
@@ -554,7 +815,13 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
|
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
|
||||||
id, wins[0][0], wins[0][1], wins[0][2], wins[1][0], wins[1][1], wins[1][2],
|
id,
|
||||||
|
wins[0][0],
|
||||||
|
wins[0][1],
|
||||||
|
wins[0][2],
|
||||||
|
wins[1][0],
|
||||||
|
wins[1][1],
|
||||||
|
wins[1][2],
|
||||||
format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b))
|
format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,29 +859,46 @@ mod tests {
|
|||||||
{"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]},
|
{"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]},
|
||||||
{"name": "Work with us", "description": "For crews."}]});
|
{"name": "Work with us", "description": "For crews."}]});
|
||||||
let rich = page_text(&step, Some(&repo_page), Some(&next), false);
|
let rich = page_text(&step, Some(&repo_page), Some(&next), false);
|
||||||
assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email"));
|
assert!(
|
||||||
|
rich.contains("Button: Send it")
|
||||||
|
&& rich.contains("We answer within two days.")
|
||||||
|
&& rich.contains("Asks for: Email")
|
||||||
|
);
|
||||||
assert!(!rich.contains("asks for: Email, The build"));
|
assert!(!rich.contains("asks for: Email, The build"));
|
||||||
let twin = page_text(&step, Some(&repo_page), Some(&next), true);
|
let twin = page_text(&step, Some(&repo_page), Some(&next), true);
|
||||||
assert!(!twin.contains("Send it") && !twin.contains("Your brief"));
|
assert!(!twin.contains("Send it") && !twin.contains("Your brief"));
|
||||||
let twin = page_text(&step, None, Some(&next), true);
|
let twin = page_text(&step, None, Some(&next), true);
|
||||||
assert!(twin.contains("What brings you here?") && twin.contains("Option 2"));
|
assert!(twin.contains("What brings you here?") && twin.contains("Option 2"));
|
||||||
assert!(!twin.contains("asks for"));
|
assert!(!twin.contains("asks for"));
|
||||||
assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager"));
|
assert!(
|
||||||
|
!twin.contains("Get a price")
|
||||||
|
&& !twin.contains("Email")
|
||||||
|
&& !twin.contains("project manager")
|
||||||
|
);
|
||||||
// Readability is measured on the sentences, not the headings.
|
// Readability is measured on the sentences, not the headings.
|
||||||
let prose = prose_text(&step, Some(&next));
|
let prose = prose_text(&step, Some(&next));
|
||||||
assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it.");
|
assert_eq!(
|
||||||
|
prose,
|
||||||
|
"What brings you here?\nFor owners.\nFor crews.\nA project manager reads it."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() {
|
fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() {
|
||||||
let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel");
|
let got = as_received(
|
||||||
|
"Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}",
|
||||||
|
"Tomter Vel",
|
||||||
|
);
|
||||||
assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}");
|
assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}");
|
||||||
let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap();
|
std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap();
|
||||||
std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap();
|
std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap();
|
||||||
let mails = mail_texts(&dir).unwrap();
|
let mails = mail_texts(&dir).unwrap();
|
||||||
assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(), ["b:open", "invite"]);
|
assert_eq!(
|
||||||
|
mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
|
||||||
|
["b:open", "invite"]
|
||||||
|
);
|
||||||
assert_eq!(mails[0].2, "Vi har det, .");
|
assert_eq!(mails[0].2, "Vi har det, .");
|
||||||
std::fs::remove_dir_all(dir).ok();
|
std::fs::remove_dir_all(dir).ok();
|
||||||
}
|
}
|
||||||
@@ -623,8 +907,16 @@ mod tests {
|
|||||||
fn the_followup_page_is_read_from_the_repo_by_following_the_action() {
|
fn the_followup_page_is_read_from_the_repo_by_following_the_action() {
|
||||||
let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(dir.join("questions/received")).unwrap();
|
std::fs::create_dir_all(dir.join("questions/received")).unwrap();
|
||||||
std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap();
|
std::fs::write(
|
||||||
std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap();
|
dir.join("questions/index.yaml"),
|
||||||
|
"name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("questions/received/price.yaml"),
|
||||||
|
"name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let step = json!({"page": "/", "alternative": "Get a price"});
|
let step = json!({"page": "/", "alternative": "Get a price"});
|
||||||
let next = followup_page(Some(&dir), &step).unwrap();
|
let next = followup_page(Some(&dir), &step).unwrap();
|
||||||
assert_eq!(next["name"], "What happens now?");
|
assert_eq!(next["name"], "What happens now?");
|
||||||
|
|||||||
@@ -190,7 +190,16 @@ not done. Its messages are exact:
|
|||||||
- `no desk gated on "g" reads "b"`: add the desk.
|
- `no desk gated on "g" reads "b"`: add the desk.
|
||||||
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
||||||
is missing a transition the state machine allows, or the state
|
is missing a transition the state machine allows, or the state
|
||||||
machine is missing the edge.
|
machine is missing the edge. The message ends in `fix:` with the
|
||||||
|
exact `{ from, to, label }` and the desk page to put it on - do that.
|
||||||
|
- `state "s" is never reached`: nothing moves a record into it. Offer
|
||||||
|
the move it names, or remove the state.
|
||||||
|
- `stage "b" comes after "a" ... but no move leads on`: the machine
|
||||||
|
cannot go the way the business works; add the edge it names.
|
||||||
|
- `the mail on entering "s" uses {x}`: the mail names a field no form
|
||||||
|
collects. Use one of the fields it lists, or collect `x`.
|
||||||
|
- WARN `the move "a" -> "b" is declared but no desk ... offers it`:
|
||||||
|
offer it, or drop the edge - every edge should be a button.
|
||||||
- `action ... does not match any declared question id`: the page does
|
- `action ... does not match any declared question id`: the page does
|
||||||
not exist.
|
not exist.
|
||||||
- `entry page ... names no one responsible`: add `responsible`.
|
- `entry page ... names no one responsible`: add `responsible`.
|
||||||
|
|||||||
+84
-17
@@ -47,19 +47,35 @@ struct Showing {
|
|||||||
/// Per bucket, per storyline index, how many arrived and how many
|
/// Per bucket, per storyline index, how many arrived and how many
|
||||||
/// ended in a state the need calls success (or, without `success`,
|
/// ended in a state the need calls success (or, without `success`,
|
||||||
/// any finished state).
|
/// any finished state).
|
||||||
fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>, needs: &[Need]) -> BTreeMap<String, Vec<Showing>> {
|
fn showings(
|
||||||
|
counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
||||||
|
needs: &[Need],
|
||||||
|
) -> BTreeMap<String, Vec<Showing>> {
|
||||||
let mut out = BTreeMap::new();
|
let mut out = BTreeMap::new();
|
||||||
for (bucket, per_story) in counts {
|
for (bucket, per_story) in counts {
|
||||||
let well: Vec<&str> = needs
|
let well: Vec<&str> = needs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|n| &n.lands_in == bucket)
|
.filter(|n| &n.lands_in == bucket)
|
||||||
.flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() })
|
.flat_map(|n| {
|
||||||
|
if n.success.is_empty() {
|
||||||
|
n.done_when.iter()
|
||||||
|
} else {
|
||||||
|
n.success.iter()
|
||||||
|
}
|
||||||
|
})
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.collect();
|
.collect();
|
||||||
let arity = per_story.keys().filter_map(|k| k.parse::<usize>().ok()).max().map(|m| m + 1).unwrap_or(1);
|
let arity = per_story
|
||||||
|
.keys()
|
||||||
|
.filter_map(|k| k.parse::<usize>().ok())
|
||||||
|
.max()
|
||||||
|
.map(|m| m + 1)
|
||||||
|
.unwrap_or(1);
|
||||||
let mut rows: Vec<Showing> = (0..arity).map(|_| Showing::default()).collect();
|
let mut rows: Vec<Showing> = (0..arity).map(|_| Showing::default()).collect();
|
||||||
for (story, states) in per_story {
|
for (story, states) in per_story {
|
||||||
let Ok(n) = story.parse::<usize>() else { continue };
|
let Ok(n) = story.parse::<usize>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
for (state, count) in states {
|
for (state, count) in states {
|
||||||
rows[n].arrived += count;
|
rows[n].arrived += count;
|
||||||
if well.contains(&state.as_str()) {
|
if well.contains(&state.as_str()) {
|
||||||
@@ -73,16 +89,32 @@ fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||||
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
|
let value = |flag: &str| {
|
||||||
let site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]"))?;
|
argv.iter()
|
||||||
|
.position(|a| a == flag)
|
||||||
|
.and_then(|i| argv.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
|
let site = value("--site").ok_or_else(|| {
|
||||||
|
anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]")
|
||||||
|
})?;
|
||||||
let url = format!("{}/report", site.trim_end_matches('/'));
|
let url = format!("{}/report", site.trim_end_matches('/'));
|
||||||
let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?;
|
let fetched = std::process::Command::new("curl")
|
||||||
|
.args(["-sSf", "--max-time", "30", &url])
|
||||||
|
.output()
|
||||||
|
.context("running curl")?;
|
||||||
if !fetched.status.success() {
|
if !fetched.status.success() {
|
||||||
bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim());
|
bail!(
|
||||||
|
"could not read {url}: {}",
|
||||||
|
String::from_utf8_lossy(&fetched.stderr).trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
|
let report: Report =
|
||||||
|
serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
|
||||||
let needs: Needs = match value("--needs") {
|
let needs: Needs = match value("--needs") {
|
||||||
Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?,
|
Some(path) => serde_yaml::from_str(
|
||||||
|
&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?,
|
||||||
|
)?,
|
||||||
None => Needs::default(),
|
None => Needs::default(),
|
||||||
};
|
};
|
||||||
println!("{site}: {} storyline(s)", report.storylines);
|
println!("{site}: {} storyline(s)", report.storylines);
|
||||||
@@ -97,11 +129,26 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
for (bucket, per_story) in &rows {
|
for (bucket, per_story) in &rows {
|
||||||
println!("\n{bucket}");
|
println!("\n{bucket}");
|
||||||
for (n, showing) in per_story.iter().enumerate() {
|
for (n, showing) in per_story.iter().enumerate() {
|
||||||
let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() };
|
let rate = if showing.arrived > 0 {
|
||||||
println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well);
|
format!(
|
||||||
|
"{:.0}%",
|
||||||
|
100.0 * showing.ended_well as f64 / showing.arrived as f64
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" storyline {n}: {} arrived, {} ended well ({rate})",
|
||||||
|
showing.arrived, showing.ended_well
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if per_story.len() > 1 {
|
if per_story.len() > 1 {
|
||||||
let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0);
|
let best = per_story
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by_key(|(_, s)| (s.ended_well, s.arrived))
|
||||||
|
.map(|(n, _)| n)
|
||||||
|
.unwrap_or(0);
|
||||||
println!(" most so far: storyline {best}");
|
println!(" most so far: storyline {best}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,12 +163,32 @@ mod tests {
|
|||||||
fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() {
|
fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() {
|
||||||
let counts: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>> =
|
let counts: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>> =
|
||||||
serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap();
|
serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap();
|
||||||
let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }];
|
let needs = vec![Need {
|
||||||
|
lands_in: "hazards".into(),
|
||||||
|
done_when: vec!["fixed".into(), "dropped".into()],
|
||||||
|
success: vec!["fixed".into()],
|
||||||
|
}];
|
||||||
let rows = showings(&counts, &needs);
|
let rows = showings(&counts, &needs);
|
||||||
assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 });
|
assert_eq!(
|
||||||
assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 });
|
rows["hazards"][0],
|
||||||
|
Showing {
|
||||||
|
arrived: 7,
|
||||||
|
ended_well: 4
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows["hazards"][1],
|
||||||
|
Showing {
|
||||||
|
arrived: 12,
|
||||||
|
ended_well: 9
|
||||||
|
}
|
||||||
|
);
|
||||||
// Without a success list, any finished state counts.
|
// Without a success list, any finished state counts.
|
||||||
let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }];
|
let any = vec![Need {
|
||||||
|
lands_in: "hazards".into(),
|
||||||
|
done_when: vec!["fixed".into(), "dropped".into()],
|
||||||
|
success: vec![],
|
||||||
|
}];
|
||||||
assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11);
|
assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-14
@@ -11,7 +11,12 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||||
let flag = |name: &str| argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned();
|
let flag = |name: &str| {
|
||||||
|
argv.iter()
|
||||||
|
.position(|a| a == name)
|
||||||
|
.and_then(|i| argv.get(i + 1))
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
let usage = "usage: portal-trainer score --site <content dir> --out <dir> [--iris <path>] [--llm 'cmd']";
|
let usage = "usage: portal-trainer score --site <content dir> --out <dir> [--iris <path>] [--llm 'cmd']";
|
||||||
let site = PathBuf::from(flag("--site").ok_or_else(|| anyhow!(usage))?);
|
let site = PathBuf::from(flag("--site").ok_or_else(|| anyhow!(usage))?);
|
||||||
let out = PathBuf::from(flag("--out").ok_or_else(|| anyhow!(usage))?);
|
let out = PathBuf::from(flag("--out").ok_or_else(|| anyhow!(usage))?);
|
||||||
@@ -27,52 +32,102 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
let tasks_skim = out.join("tasks-skim.jsonl");
|
let tasks_skim = out.join("tasks-skim.jsonl");
|
||||||
let model = out.join("model.json");
|
let model = out.join("model.json");
|
||||||
let mut log = String::new();
|
let mut log = String::new();
|
||||||
log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)])?);
|
log.push_str(&lint(
|
||||||
log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_skim), "--skim"])?);
|
&iris,
|
||||||
|
&questions,
|
||||||
|
&["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)],
|
||||||
|
)?);
|
||||||
|
log.push_str(&lint(
|
||||||
|
&iris,
|
||||||
|
&questions,
|
||||||
|
&["--needs-tasks", &s(&tasks_skim), "--skim"],
|
||||||
|
)?);
|
||||||
std::fs::write(out.join("iris.log"), &log)?;
|
std::fs::write(out.join("iris.log"), &log)?;
|
||||||
|
|
||||||
let mut summary = serde_json::Map::new();
|
let mut summary = serde_json::Map::new();
|
||||||
for (mode, tasks, skim) in [("full", &tasks_full, false), ("skim", &tasks_skim, true)] {
|
for (mode, tasks, skim) in [("full", &tasks_full, false), ("skim", &tasks_skim, true)] {
|
||||||
eprintln!("score: the visitor answers {mode}");
|
eprintln!("score: the visitor answers {mode}");
|
||||||
let answers = crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?;
|
let answers =
|
||||||
|
crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?;
|
||||||
let answers_path = out.join(format!("answers-{mode}.jsonl"));
|
let answers_path = out.join(format!("answers-{mode}.jsonl"));
|
||||||
std::fs::write(&answers_path, &answers)?;
|
std::fs::write(&answers_path, &answers)?;
|
||||||
let (tasks_arg, answers_arg) = (s(tasks), s(&answers_path));
|
let (tasks_arg, answers_arg) = (s(tasks), s(&answers_path));
|
||||||
let mut args = vec!["--needs-tasks", tasks_arg.as_str(), "--needs-score", answers_arg.as_str()];
|
let mut args = vec![
|
||||||
|
"--needs-tasks",
|
||||||
|
tasks_arg.as_str(),
|
||||||
|
"--needs-score",
|
||||||
|
answers_arg.as_str(),
|
||||||
|
];
|
||||||
if skim {
|
if skim {
|
||||||
args.push("--skim");
|
args.push("--skim");
|
||||||
}
|
}
|
||||||
let report = lint(&iris, &questions, &args)?;
|
let report = lint(&iris, &questions, &args)?;
|
||||||
println!("== {}", if skim { "headings only" } else { "with descriptions" });
|
println!(
|
||||||
|
"== {}",
|
||||||
|
if skim {
|
||||||
|
"headings only"
|
||||||
|
} else {
|
||||||
|
"with descriptions"
|
||||||
|
}
|
||||||
|
);
|
||||||
let mut right = None;
|
let mut right = None;
|
||||||
for line in report.lines() {
|
for line in report.lines() {
|
||||||
if line.starts_with("MISROUTE") || line.starts_with("HESITANT") || line.starts_with("UNANSWERED") {
|
if line.starts_with("MISROUTE")
|
||||||
|
|| line.starts_with("HESITANT")
|
||||||
|
|| line.starts_with("UNANSWERED")
|
||||||
|
{
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
if let Some(rest) = line.strip_prefix("wording: ") {
|
if let Some(rest) = line.strip_prefix("wording: ") {
|
||||||
// "wording: 19/27 routed right (70%)"
|
// "wording: 19/27 routed right (70%)"
|
||||||
let mut parts = rest.split_whitespace().next().unwrap_or_default().split('/');
|
let mut parts = rest
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split('/');
|
||||||
let a: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
let a: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||||
let b: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
let b: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||||
right = Some((a, b));
|
right = Some((a, b));
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (a, b) = right.ok_or_else(|| anyhow!("iris printed no wording score for {mode}; its output:\n{report}"))?;
|
let (a, b) = right.ok_or_else(|| {
|
||||||
|
anyhow!("iris printed no wording score for {mode}; its output:\n{report}")
|
||||||
|
})?;
|
||||||
summary.insert(mode.to_string(), serde_json::json!({ "right": a, "of": b }));
|
summary.insert(mode.to_string(), serde_json::json!({ "right": a, "of": b }));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("== rate");
|
println!("== rate");
|
||||||
let rated = out.join("rated");
|
let rated = out.join("rated");
|
||||||
let rate_argv: Vec<String> = ["rate", "--model", &s(&model), "--out", &s(&rated), "--repo", &s(&site), "--llm", &cmd].iter().map(|x| x.to_string()).collect();
|
let rate_argv: Vec<String> = [
|
||||||
|
"rate",
|
||||||
|
"--model",
|
||||||
|
&s(&model),
|
||||||
|
"--out",
|
||||||
|
&s(&rated),
|
||||||
|
"--repo",
|
||||||
|
&s(&site),
|
||||||
|
"--llm",
|
||||||
|
&cmd,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.to_string())
|
||||||
|
.collect();
|
||||||
crate::trainer::rate::rate(&rate_argv)?;
|
crate::trainer::rate::rate(&rate_argv)?;
|
||||||
|
|
||||||
summary.insert("site".into(), serde_json::json!(s(&site)));
|
summary.insert("site".into(), serde_json::json!(s(&site)));
|
||||||
summary.insert("visitor".into(), serde_json::json!(cmd));
|
summary.insert("visitor".into(), serde_json::json!(cmd));
|
||||||
std::fs::write(out.join("summary.json"), serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?)?;
|
std::fs::write(
|
||||||
|
out.join("summary.json"),
|
||||||
|
serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?,
|
||||||
|
)?;
|
||||||
println!(
|
println!(
|
||||||
"== summary: with descriptions {} of {}, headings only {} of {} (rate.json in {})",
|
"== summary: with descriptions {} of {}, headings only {} of {} (rate.json in {})",
|
||||||
summary["full"]["right"], summary["full"]["of"], summary["skim"]["right"], summary["skim"]["of"], rated.display()
|
summary["full"]["right"],
|
||||||
|
summary["full"]["of"],
|
||||||
|
summary["skim"]["right"],
|
||||||
|
summary["skim"]["of"],
|
||||||
|
rated.display()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -92,9 +147,17 @@ fn lint(iris: &str, questions: &Path, args: &[&str]) -> anyhow::Result<String> {
|
|||||||
.args(args)
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.with_context(|| format!("running {iris} (pass --iris <path> if it is not on PATH)"))?;
|
.with_context(|| format!("running {iris} (pass --iris <path> if it is not on PATH)"))?;
|
||||||
let text = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
bail!("{iris} check --path {} {} failed:\n{text}", questions.display(), args.join(" "));
|
bail!(
|
||||||
|
"{iris} check --path {} {} failed:\n{text}",
|
||||||
|
questions.display(),
|
||||||
|
args.join(" ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-37
@@ -127,7 +127,12 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()];
|
let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()];
|
||||||
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
let done: Vec<&str> = need["done_when"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.collect();
|
||||||
for (run, route) in all_routes(bucket, &done).into_iter().enumerate() {
|
for (run, route) in all_routes(bucket, &done).into_iter().enumerate() {
|
||||||
let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()];
|
let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()];
|
||||||
states.extend(route.iter().map(|m| m.to.clone()));
|
states.extend(route.iter().map(|m| m.to.clone()));
|
||||||
@@ -137,7 +142,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
run,
|
run,
|
||||||
target: states.last().cloned().unwrap_or_default(),
|
target: states.last().cloned().unwrap_or_default(),
|
||||||
record: BTreeMap::from([("note".to_string(), json!("dry run"))]),
|
record: BTreeMap::from([("note".to_string(), json!("dry run"))]),
|
||||||
pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(),
|
pressed: route
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!("{} -> {}", m.from, m.label))
|
||||||
|
.collect(),
|
||||||
moved_by: route.iter().map(|m| m.by.clone()).collect(),
|
moved_by: route.iter().map(|m| m.by.clone()).collect(),
|
||||||
touches: route.len(),
|
touches: route.len(),
|
||||||
ask_backs: Vec::new(),
|
ask_backs: Vec::new(),
|
||||||
@@ -148,9 +156,16 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
|
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
|
std::fs::write(
|
||||||
|
out.join("score.json"),
|
||||||
|
serde_json::to_string_pretty(&scores)?,
|
||||||
|
)?;
|
||||||
let relays: usize = scores.values().map(|s| s.cases).sum();
|
let relays: usize = scores.values().map(|s| s.cases).sum();
|
||||||
println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display());
|
println!(
|
||||||
|
"dry run: {relays} route(s) across {} need(s) written to {}",
|
||||||
|
scores.len(),
|
||||||
|
out.join("cases.jsonl").display()
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +195,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
|
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
|
std::fs::write(
|
||||||
|
out.join("score.json"),
|
||||||
|
serde_json::to_string_pretty(&scores)?,
|
||||||
|
)?;
|
||||||
print_scores(&scores);
|
print_scores(&scores);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -257,7 +275,8 @@ fn play(
|
|||||||
);
|
);
|
||||||
let reply = ask(visitor_cmd, &prompt, &seed(i * 10 + 1))?;
|
let reply = ask(visitor_cmd, &prompt, &seed(i * 10 + 1))?;
|
||||||
if reply["abandon"] == true {
|
if reply["abandon"] == true {
|
||||||
case.outcome = Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
|
case.outcome =
|
||||||
|
Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
|
||||||
return Ok(case);
|
return Ok(case);
|
||||||
}
|
}
|
||||||
if let Some(values) = reply["values"].as_object() {
|
if let Some(values) = reply["values"].as_object() {
|
||||||
@@ -277,9 +296,18 @@ fn play(
|
|||||||
// collected, could this role really do what the button claims?
|
// collected, could this role really do what the button claims?
|
||||||
let bucket_name = need["lands_in"].as_str().unwrap_or_default();
|
let bucket_name = need["lands_in"].as_str().unwrap_or_default();
|
||||||
let bucket = &model["buckets"][bucket_name];
|
let bucket = &model["buckets"][bucket_name];
|
||||||
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
let done: Vec<&str> = need["done_when"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.collect();
|
||||||
let initial = bucket["initial"].as_str().unwrap_or("open").to_string();
|
let initial = bucket["initial"].as_str().unwrap_or("open").to_string();
|
||||||
let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string();
|
let target = done
|
||||||
|
.get(run % done.len().max(1))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
case.target = target.clone();
|
case.target = target.clone();
|
||||||
case.states.push(initial.clone());
|
case.states.push(initial.clone());
|
||||||
let Some(route) = route_to(bucket, &initial, &target) else {
|
let Some(route) = route_to(bucket, &initial, &target) else {
|
||||||
@@ -304,7 +332,11 @@ fn play(
|
|||||||
serde_json::to_string_pretty(&case.record)?,
|
serde_json::to_string_pretty(&case.record)?,
|
||||||
);
|
);
|
||||||
let reply = ask(handler_cmd, &prompt, &seed(100 + step))?;
|
let reply = ask(handler_cmd, &prompt, &seed(100 + step))?;
|
||||||
let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string();
|
let question = reply["need_to_know"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
if reply["can_act"] == false && !question.is_empty() {
|
if reply["can_act"] == false && !question.is_empty() {
|
||||||
let answer = ask(
|
let answer = ask(
|
||||||
visitor_cmd,
|
visitor_cmd,
|
||||||
@@ -314,7 +346,10 @@ fn play(
|
|||||||
&seed(200 + step),
|
&seed(200 + step),
|
||||||
)?;
|
)?;
|
||||||
let n = case.ask_backs.len() + 1;
|
let n = case.ask_backs.len() + 1;
|
||||||
case.record.insert(format!("follow_up_{n}"), json!({ "asked": question, "answered": answer["answer"] }));
|
case.record.insert(
|
||||||
|
format!("follow_up_{n}"),
|
||||||
|
json!({ "asked": question, "answered": answer["answer"] }),
|
||||||
|
);
|
||||||
case.ask_backs.push(AskBack {
|
case.ask_backs.push(AskBack {
|
||||||
state: from.clone(),
|
state: from.clone(),
|
||||||
button: label.clone(),
|
button: label.clone(),
|
||||||
@@ -343,21 +378,47 @@ pub(crate) struct Move {
|
|||||||
/// desk, and the submitter's own link. Roles overlap on one aggregate,
|
/// desk, and the submitter's own link. Roles overlap on one aggregate,
|
||||||
/// so a route is a relay, not one desk's work.
|
/// so a route is a relay, not one desk's work.
|
||||||
pub(crate) fn all_moves(bucket: &Value) -> Vec<Move> {
|
pub(crate) fn all_moves(bucket: &Value) -> Vec<Move> {
|
||||||
let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to));
|
let legal = |from: &str, to: &str| {
|
||||||
|
bucket["transitions"][from]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|a| a.iter().any(|x| x == to))
|
||||||
|
};
|
||||||
let mut moves: Vec<Move> = Vec::new();
|
let mut moves: Vec<Move> = Vec::new();
|
||||||
for desk in bucket["desks"].as_array().into_iter().flatten() {
|
for desk in bucket["desks"].as_array().into_iter().flatten() {
|
||||||
for m in desk["moves"].as_array().into_iter().flatten() {
|
for m in desk["moves"].as_array().into_iter().flatten() {
|
||||||
let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue };
|
let (Some(from), Some(label), Some(to)) =
|
||||||
|
(m["from"].as_str(), m["label"].as_str(), m["to"].as_str())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) {
|
if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) {
|
||||||
moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() });
|
moves.push(Move {
|
||||||
|
from: from.into(),
|
||||||
|
label: label.into(),
|
||||||
|
to: to.into(),
|
||||||
|
by: desk["group"].as_str().unwrap_or("ungated").into(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let states: Vec<String> = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default();
|
let states: Vec<String> = bucket["transitions"]
|
||||||
for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
.as_object()
|
||||||
|
.map(|t| t.keys().cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for to in bucket["self_moves"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
{
|
||||||
for from in &states {
|
for from in &states {
|
||||||
if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) {
|
if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) {
|
||||||
moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() });
|
moves.push(Move {
|
||||||
|
from: from.clone(),
|
||||||
|
label: "their own link".into(),
|
||||||
|
to: to.into(),
|
||||||
|
by: SUBMITTER.into(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +449,13 @@ fn route_to(bucket: &Value, from: &str, to: &str) -> Option<Vec<Move>> {
|
|||||||
/// dry run walks, so that each button of each role is pressed at least
|
/// dry run walks, so that each button of each role is pressed at least
|
||||||
/// once against the real engine.
|
/// once against the real engine.
|
||||||
pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
|
pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
|
||||||
fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec<Move>, out: &mut Vec<Vec<Move>>) {
|
fn walk(
|
||||||
|
state: &str,
|
||||||
|
done: &[&str],
|
||||||
|
moves: &[Move],
|
||||||
|
path: &mut Vec<Move>,
|
||||||
|
out: &mut Vec<Vec<Move>>,
|
||||||
|
) {
|
||||||
if done.contains(&state) {
|
if done.contains(&state) {
|
||||||
out.push(path.clone());
|
out.push(path.clone());
|
||||||
return;
|
return;
|
||||||
@@ -403,7 +470,13 @@ pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out);
|
walk(
|
||||||
|
bucket["initial"].as_str().unwrap_or("open"),
|
||||||
|
done,
|
||||||
|
&all_moves(bucket),
|
||||||
|
&mut Vec::new(),
|
||||||
|
&mut out,
|
||||||
|
);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +497,11 @@ pub(crate) fn resolve_choice(names: &[&str], raw: &str) -> String {
|
|||||||
/// be (`FORMAT=json`, honoured by engines/ollama.sh) and the first
|
/// be (`FORMAT=json`, honoured by engines/ollama.sh) and the first
|
||||||
/// object in the reply is taken either way.
|
/// object in the reply is taken either way.
|
||||||
pub(crate) fn ask(cmd: &str, prompt: &str, seed: &str) -> anyhow::Result<Value> {
|
pub(crate) fn ask(cmd: &str, prompt: &str, seed: &str) -> anyhow::Result<Value> {
|
||||||
let reply = llm_with(cmd, prompt, &[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")])?;
|
let reply = llm_with(
|
||||||
|
cmd,
|
||||||
|
prompt,
|
||||||
|
&[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")],
|
||||||
|
)?;
|
||||||
Ok(first_object(&reply).unwrap_or(Value::Null))
|
Ok(first_object(&reply).unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +539,11 @@ fn tally(score: &mut NeedScore, case: &Case) {
|
|||||||
score.buttons.entry(button.clone()).or_default().0 += 1;
|
score.buttons.entry(button.clone()).or_default().0 += 1;
|
||||||
}
|
}
|
||||||
for a in &case.ask_backs {
|
for a in &case.ask_backs {
|
||||||
score.buttons.entry(format!("{} -> {}", a.state, a.button)).or_default().1 += 1;
|
score
|
||||||
|
.buttons
|
||||||
|
.entry(format!("{} -> {}", a.state, a.button))
|
||||||
|
.or_default()
|
||||||
|
.1 += 1;
|
||||||
}
|
}
|
||||||
match &case.outcome {
|
match &case.outcome {
|
||||||
Outcome::Finished(_) => score.finished += 1,
|
Outcome::Finished(_) => score.finished += 1,
|
||||||
@@ -476,7 +557,9 @@ fn tally(score: &mut NeedScore, case: &Case) {
|
|||||||
}
|
}
|
||||||
Outcome::Abandoned(why) => {
|
Outcome::Abandoned(why) => {
|
||||||
score.abandoned += 1;
|
score.abandoned += 1;
|
||||||
score.abandon_reasons.push(format!("{}: {why}", case.persona));
|
score
|
||||||
|
.abandon_reasons
|
||||||
|
.push(format!("{}: {why}", case.persona));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,7 +584,15 @@ fn per_case(n: usize, s: &NeedScore) -> String {
|
|||||||
fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
||||||
println!(
|
println!(
|
||||||
"\n{:<28} {:>5} {:>9} {:>9} {:>10} {:>9} {:>8} {:>10} {:>13}",
|
"\n{:<28} {:>5} {:>9} {:>9} {:>10} {:>9} {:>8} {:>10} {:>13}",
|
||||||
"need", "cases", "finished", "misroute", "abandoned", "no route", "touches", "ask-backs", "unanswerable"
|
"need",
|
||||||
|
"cases",
|
||||||
|
"finished",
|
||||||
|
"misroute",
|
||||||
|
"abandoned",
|
||||||
|
"no route",
|
||||||
|
"touches",
|
||||||
|
"ask-backs",
|
||||||
|
"unanswerable"
|
||||||
);
|
);
|
||||||
for (need, s) in scores {
|
for (need, s) in scores {
|
||||||
println!(
|
println!(
|
||||||
@@ -529,7 +620,11 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
|||||||
" {need} [{} / {}]{} {}",
|
" {need} [{} / {}]{} {}",
|
||||||
a.state,
|
a.state,
|
||||||
a.button,
|
a.button,
|
||||||
if a.answerable { "" } else { " (visitor did not know)" },
|
if a.answerable {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (visitor did not know)"
|
||||||
|
},
|
||||||
a.question
|
a.question
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -550,10 +645,16 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
|||||||
pub fn compare(argv: &[String]) -> anyhow::Result<()> {
|
pub fn compare(argv: &[String]) -> anyhow::Result<()> {
|
||||||
let (a, b) = match (argv.get(1), argv.get(2)) {
|
let (a, b) = match (argv.get(1), argv.get(2)) {
|
||||||
(Some(a), Some(b)) => (a, b),
|
(Some(a), Some(b)) => (a, b),
|
||||||
_ => return Err(anyhow!("usage: portal-trainer compare <a/score.json> <b/score.json>")),
|
_ => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"usage: portal-trainer compare <a/score.json> <b/score.json>"
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let load = |p: &String| -> anyhow::Result<BTreeMap<String, NeedScore>> {
|
let load = |p: &String| -> anyhow::Result<BTreeMap<String, NeedScore>> {
|
||||||
Ok(serde_json::from_str(&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?)?)
|
Ok(serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?,
|
||||||
|
)?)
|
||||||
};
|
};
|
||||||
let (a, b) = (load(a)?, load(b)?);
|
let (a, b) = (load(a)?, load(b)?);
|
||||||
println!(
|
println!(
|
||||||
@@ -565,11 +666,27 @@ pub fn compare(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
println!(
|
println!(
|
||||||
"{:<28} {:>14} {:>14} {:>14} {:>16} {:>16}",
|
"{:<28} {:>14} {:>14} {:>14} {:>16} {:>16}",
|
||||||
need,
|
need,
|
||||||
format!("{} -> {}", pct(sa.misrouted, sa.cases), pct(sb.misrouted, sb.cases)),
|
format!(
|
||||||
format!("{} -> {}", pct(sa.abandoned, sa.cases), pct(sb.abandoned, sb.cases)),
|
"{} -> {}",
|
||||||
|
pct(sa.misrouted, sa.cases),
|
||||||
|
pct(sb.misrouted, sb.cases)
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"{} -> {}",
|
||||||
|
pct(sa.abandoned, sa.cases),
|
||||||
|
pct(sb.abandoned, sb.cases)
|
||||||
|
),
|
||||||
format!("{} -> {}", pct(sa.stuck, sa.cases), pct(sb.stuck, sb.cases)),
|
format!("{} -> {}", pct(sa.stuck, sa.cases), pct(sb.stuck, sb.cases)),
|
||||||
format!("{} -> {}", per_case(sa.touches, sa), per_case(sb.touches, sb)),
|
format!(
|
||||||
format!("{} -> {}", per_case(sa.ask_backs, sa), per_case(sb.ask_backs, sb)),
|
"{} -> {}",
|
||||||
|
per_case(sa.touches, sa),
|
||||||
|
per_case(sb.touches, sb)
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"{} -> {}",
|
||||||
|
per_case(sa.ask_backs, sa),
|
||||||
|
per_case(sb.ask_backs, sb)
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -589,8 +706,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_choice_is_matched_by_its_heading_however_the_model_dresses_it() {
|
fn a_choice_is_matched_by_its_heading_however_the_model_dresses_it() {
|
||||||
let names = ["Ask about working", "Ask about working with us", "Get a price"];
|
let names = [
|
||||||
assert_eq!(resolve_choice(&names, "Ask about working with us: For carpenters who..."), "Ask about working with us");
|
"Ask about working",
|
||||||
|
"Ask about working with us",
|
||||||
|
"Get a price",
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_choice(&names, "Ask about working with us: For carpenters who..."),
|
||||||
|
"Ask about working with us"
|
||||||
|
);
|
||||||
assert_eq!(resolve_choice(&names, " get a PRICE "), "Get a price");
|
assert_eq!(resolve_choice(&names, " get a PRICE "), "Get a price");
|
||||||
assert_eq!(resolve_choice(&names, "Something else"), "Something else");
|
assert_eq!(resolve_choice(&names, "Something else"), "Something else");
|
||||||
}
|
}
|
||||||
@@ -620,7 +744,10 @@ mod tests {
|
|||||||
// Losing a quoted job is the other group's button, so the way
|
// Losing a quoted job is the other group's button, so the way
|
||||||
// from open to that ending is a relay: two roles, one case.
|
// from open to that ending is a relay: two roles, one case.
|
||||||
let route = route_to(&bucket(), "surveyed", "lost").unwrap();
|
let route = route_to(&bucket(), "surveyed", "lost").unwrap();
|
||||||
let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect();
|
let relay: Vec<(&str, &str)> = route
|
||||||
|
.iter()
|
||||||
|
.map(|m| (m.label.as_str(), m.by.as_str()))
|
||||||
|
.collect();
|
||||||
assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]);
|
assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]);
|
||||||
assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0);
|
assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0);
|
||||||
assert!(route_to(&bucket(), "won", "open").is_none());
|
assert!(route_to(&bucket(), "won", "open").is_none());
|
||||||
@@ -630,7 +757,10 @@ mod tests {
|
|||||||
fn the_submitters_own_link_is_a_move_like_any_other() {
|
fn the_submitters_own_link_is_a_move_like_any_other() {
|
||||||
let mut b = bucket();
|
let mut b = bucket();
|
||||||
b["self_moves"] = json!(["lost"]);
|
b["self_moves"] = json!(["lost"]);
|
||||||
let theirs: Vec<Move> = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect();
|
let theirs: Vec<Move> = all_moves(&b)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| m.by == SUBMITTER)
|
||||||
|
.collect();
|
||||||
// Only where the machine allows it and no desk already holds it:
|
// Only where the machine allows it and no desk already holds it:
|
||||||
// open->lost is the project manager's and quoted->lost the other
|
// open->lost is the project manager's and quoted->lost the other
|
||||||
// group's, which leaves surveyed->lost to the link.
|
// group's, which leaves surveyed->lost to the link.
|
||||||
@@ -641,7 +771,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_dry_run_walks_every_route_to_every_ending() {
|
fn a_dry_run_walks_every_route_to_every_ending() {
|
||||||
let routes = all_routes(&bucket(), &["won", "lost"]);
|
let routes = all_routes(&bucket(), &["won", "lost"]);
|
||||||
let ends: Vec<String> = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect();
|
let ends: Vec<String> = routes
|
||||||
|
.iter()
|
||||||
|
.map(|r| format!("{}:{}", r.len(), r.last().unwrap().to))
|
||||||
|
.collect();
|
||||||
// open->lost; open->surveyed->quoted->won; and the relay
|
// open->lost; open->surveyed->quoted->won; and the relay
|
||||||
// open->surveyed->quoted->lost, closed by the other group.
|
// open->surveyed->quoted->lost, closed by the other group.
|
||||||
assert_eq!(ends.len(), 3, "{ends:?}");
|
assert_eq!(ends.len(), 3, "{ends:?}");
|
||||||
@@ -664,15 +797,28 @@ mod tests {
|
|||||||
moved_by: vec!["pm".into(), "pm".into()],
|
moved_by: vec!["pm".into(), "pm".into()],
|
||||||
touches: 3,
|
touches: 3,
|
||||||
ask_backs: vec![
|
ask_backs: vec![
|
||||||
AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true },
|
AskBack {
|
||||||
AskBack { state: "surveyed".into(), button: "Quote sent".into(), question: "Load-bearing?".into(), answerable: false },
|
state: "open".into(),
|
||||||
|
button: "Looked".into(),
|
||||||
|
question: "Which floor?".into(),
|
||||||
|
answerable: true,
|
||||||
|
},
|
||||||
|
AskBack {
|
||||||
|
state: "surveyed".into(),
|
||||||
|
button: "Quote sent".into(),
|
||||||
|
question: "Load-bearing?".into(),
|
||||||
|
answerable: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
outcome,
|
outcome,
|
||||||
};
|
};
|
||||||
tally(&mut score, &base(Outcome::Finished("won".into())));
|
tally(&mut score, &base(Outcome::Finished("won".into())));
|
||||||
tally(&mut score, &base(Outcome::Stuck("no route".into())));
|
tally(&mut score, &base(Outcome::Stuck("no route".into())));
|
||||||
tally(&mut score, &base(Outcome::Misrouted("Wrong door".into())));
|
tally(&mut score, &base(Outcome::Misrouted("Wrong door".into())));
|
||||||
assert_eq!((score.cases, score.finished, score.stuck, score.misrouted), (3, 1, 1, 1));
|
assert_eq!(
|
||||||
|
(score.cases, score.finished, score.stuck, score.misrouted),
|
||||||
|
(3, 1, 1, 1)
|
||||||
|
);
|
||||||
assert_eq!((score.ask_backs, score.unanswerable), (6, 3));
|
assert_eq!((score.ask_backs, score.unanswerable), (6, 3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-11
@@ -130,13 +130,18 @@ pub async fn upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (filename, content_type, bytes) =
|
let (filename, content_type, bytes) = file.ok_or((
|
||||||
file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?;
|
StatusCode::BAD_REQUEST,
|
||||||
|
"no file field in request".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
// Nothing has been written yet. Everything below decides whether
|
// Nothing has been written yet. Everything below decides whether
|
||||||
// anything will be.
|
// anything will be.
|
||||||
if !may_upload(&state, &session, &question_id, &alternative).await {
|
if !may_upload(&state, &session, &question_id, &alternative).await {
|
||||||
return Err((StatusCode::FORBIDDEN, "not authorized to upload here".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"not authorized to upload here".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
|
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
|
||||||
@@ -235,7 +240,10 @@ alternatives:
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_upload_must_name_a_page_that_asked_for_a_file() {
|
fn an_upload_must_name_a_page_that_asked_for_a_file() {
|
||||||
let qs = questions(PAGE);
|
let qs = questions(PAGE);
|
||||||
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string()));
|
assert_eq!(
|
||||||
|
page_taking_files(&qs, "/", "Send drawings"),
|
||||||
|
Some("/".to_string())
|
||||||
|
);
|
||||||
// An alternative on the same page that takes no file.
|
// An alternative on the same page that takes no file.
|
||||||
assert_eq!(page_taking_files(&qs, "/", "Just ask"), None);
|
assert_eq!(page_taking_files(&qs, "/", "Just ask"), None);
|
||||||
// Names that are not there at all.
|
// Names that are not there at all.
|
||||||
@@ -246,7 +254,10 @@ alternatives:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_closed_alternative_takes_nothing() {
|
fn a_closed_alternative_takes_nothing() {
|
||||||
let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n"));
|
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);
|
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,15 +288,24 @@ pub async fn attachment(
|
|||||||
// cannot be used to learn which buckets, records or fields exist.
|
// cannot be used to learn which buckets, records or fields exist.
|
||||||
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
||||||
|
|
||||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
|
let store = state
|
||||||
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
|
.jetstream
|
||||||
|
.get_key_value(&bucket)
|
||||||
|
.await
|
||||||
|
.map_err(|_| missing())?;
|
||||||
|
let bytes = store
|
||||||
|
.get(&record)
|
||||||
|
.await
|
||||||
|
.map_err(|_| missing())?
|
||||||
|
.ok_or_else(missing)?;
|
||||||
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
||||||
// Through a list the caller may read, and one that shows this
|
// Through a list the caller may read, and one that shows this
|
||||||
// record: a list of "my cases" opens my files and nobody else's.
|
// record: a list of "my cases" opens my files and nobody else's.
|
||||||
if !may_read_record(&state, &session, &bucket, &as_listed).await {
|
if !may_read_record(&state, &session, &bucket, &as_listed).await {
|
||||||
return Err(missing());
|
return Err(missing());
|
||||||
}
|
}
|
||||||
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
|
let answer: crate::answers::Answer =
|
||||||
|
serde_json::from_value(as_listed).map_err(|_| missing())?;
|
||||||
let key = answer
|
let key = answer
|
||||||
.responses
|
.responses
|
||||||
.get(&field)
|
.get(&field)
|
||||||
@@ -310,13 +330,22 @@ pub async fn attachment(
|
|||||||
.body
|
.body
|
||||||
.collect()
|
.collect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))?
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("reading the file failed: {e}"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
.into_bytes();
|
.into_bytes();
|
||||||
|
|
||||||
// The stored key is `<uuid>-<original name>`; the person who gets
|
// The stored key is `<uuid>-<original name>`; the person who gets
|
||||||
// the file should see the name they sent, not the uuid.
|
// the file should see the name they sent, not the uuid.
|
||||||
let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key);
|
let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key);
|
||||||
let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], "");
|
let filename = filename
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(filename)
|
||||||
|
.replace(['"', '\\'], "");
|
||||||
Ok((
|
Ok((
|
||||||
[
|
[
|
||||||
(axum::http::header::CONTENT_TYPE, content_type),
|
(axum::http::header::CONTENT_TYPE, content_type),
|
||||||
@@ -349,7 +378,10 @@ async fn may_read_record(
|
|||||||
question.alternatives.iter().any(|alternative| {
|
question.alternatives.iter().any(|alternative| {
|
||||||
alternative.features.iter().any(|feature| {
|
alternative.features.iter().any(|feature| {
|
||||||
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
||||||
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms))
|
&& feature
|
||||||
|
.resource
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms))
|
||||||
&& access.allows(
|
&& access.allows(
|
||||||
&principal,
|
&principal,
|
||||||
crate::access::Action::Read,
|
crate::access::Action::Read,
|
||||||
|
|||||||
@@ -1280,6 +1280,31 @@ textarea:focus {
|
|||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* After the newest finished cases: the older ones, on request. Quiet,
|
||||||
|
since the desk is for what is still moving. */
|
||||||
|
.show-older {
|
||||||
|
justify-self: start;
|
||||||
|
background: none;
|
||||||
|
border: 0.06rem solid var(--line);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A releases manifest: the pick for this device, the rest folded. */
|
||||||
|
.releases .pill { display: inline-block; }
|
||||||
|
.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; }
|
||||||
|
.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; }
|
||||||
|
.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.show-older:hover {
|
||||||
|
color: inherit;
|
||||||
|
border-color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.answer-row {
|
.answer-row {
|
||||||
border: 0.06rem solid var(--line);
|
border: 0.06rem solid var(--line);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
|
|||||||
+223
-45
@@ -34,27 +34,51 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
|||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
// The submission: a KV record in the initial state, plus the first event.
|
// 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, 0)
|
store_answer(
|
||||||
.await
|
&js,
|
||||||
.expect("store");
|
&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");
|
let store = js.get_key_value(&bucket).await.expect("bucket exists");
|
||||||
let stored: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
let stored: Answer =
|
||||||
|
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||||
assert_eq!(stored.state, "open");
|
assert_eq!(stored.state, "open");
|
||||||
assert_eq!(stored.responses["email"], "a@b.example");
|
assert_eq!(stored.responses["email"], "a@b.example");
|
||||||
|
|
||||||
// A desk move the machine allows.
|
// A desk move the machine allows.
|
||||||
let moved = aggregates::transition(&js, schema, "rec-1", "quoted", json!({"by": "test"}), now + 1)
|
let moved = aggregates::transition(
|
||||||
.await
|
&js,
|
||||||
.expect("open -> quoted is declared");
|
schema,
|
||||||
|
"rec-1",
|
||||||
|
"quoted",
|
||||||
|
json!({"by": "test"}),
|
||||||
|
now + 1,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("open -> quoted is declared");
|
||||||
assert_eq!(moved.state, "quoted");
|
assert_eq!(moved.state, "quoted");
|
||||||
|
|
||||||
// Moves the machine does not allow are refused, whoever asks.
|
// Moves the machine does not allow are refused, whoever asks.
|
||||||
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
|
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
|
||||||
assert!(matches!(back, Err(TransitionError::DisallowedTransition { .. })), "{back:?}");
|
assert!(
|
||||||
|
matches!(back, Err(TransitionError::DisallowedTransition { .. })),
|
||||||
|
"{back:?}"
|
||||||
|
);
|
||||||
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
|
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
|
||||||
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
|
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
|
||||||
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
|
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
|
||||||
assert!(matches!(nobody, Err(TransitionError::UnknownAggregate)), "{nobody:?}");
|
assert!(
|
||||||
|
matches!(nobody, Err(TransitionError::UnknownAggregate)),
|
||||||
|
"{nobody:?}"
|
||||||
|
);
|
||||||
|
|
||||||
// The record reads back from its own event log, in the state the
|
// The record reads back from its own event log, in the state the
|
||||||
// accepted moves left it, with a sequence that moved forward.
|
// accepted moves left it, with a sequence that moved forward.
|
||||||
@@ -67,7 +91,8 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
|||||||
// A reply to the mail this record was sent becomes a note on it,
|
// 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
|
// through the same store the desks read - and moves nothing: the
|
||||||
// record is still where the desk left it.
|
// 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 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 state_before_the_note = record.state.clone();
|
||||||
let reply = portal::mail::Incoming {
|
let reply = portal::mail::Incoming {
|
||||||
v: portal::mail::CONTRACT,
|
v: portal::mail::CONTRACT,
|
||||||
@@ -81,13 +106,20 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
|||||||
};
|
};
|
||||||
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
|
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
|
||||||
record.notes.push(note);
|
record.notes.push(note);
|
||||||
store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored");
|
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();
|
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.len(), 1);
|
||||||
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
|
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
|
||||||
assert_eq!(read_back.notes[0].from, "a@b.example");
|
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");
|
assert_eq!(
|
||||||
|
read_back.state, state_before_the_note,
|
||||||
|
"a reply is a note, never a decision"
|
||||||
|
);
|
||||||
// The same reply read twice adds nothing.
|
// The same reply read twice adds nothing.
|
||||||
assert!(portal::mail::note_for(&reply, &read_back).is_err());
|
assert!(portal::mail::note_for(&reply, &read_back).is_err());
|
||||||
// And the event log is untouched by it.
|
// And the event log is untouched by it.
|
||||||
@@ -122,17 +154,39 @@ async fn a_session_survives_the_process_that_made_it() {
|
|||||||
|
|
||||||
// A second store over the same JetStream is what the next boot of
|
// A second store over the same JetStream is what the next boot of
|
||||||
// the binary has: nothing carried over in memory.
|
// the binary has: nothing carried over in memory.
|
||||||
let next_boot = portal::sessions::NatsStore::open(&js).await.expect("reopen");
|
let next_boot = portal::sessions::NatsStore::open(&js)
|
||||||
let found = next_boot.load(&record.id).await.expect("load").expect("still there");
|
.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")));
|
assert_eq!(found.data.get("who"), Some(&json!("ingrid")));
|
||||||
|
|
||||||
// `create` never writes over somebody else's session: asked to
|
// `create` never writes over somebody else's session: asked to
|
||||||
// create one whose id is taken, it takes a new id instead.
|
// 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 };
|
let mut collide = Record {
|
||||||
next_boot.create(&mut collide).await.expect("create beside it");
|
id: record.id,
|
||||||
assert_ne!(collide.id, record.id, "an id already in use must not be reused");
|
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!(
|
assert_eq!(
|
||||||
next_boot.load(&record.id).await.expect("load").expect("original intact").data.get("who"),
|
next_boot
|
||||||
|
.load(&record.id)
|
||||||
|
.await
|
||||||
|
.expect("load")
|
||||||
|
.expect("original intact")
|
||||||
|
.data
|
||||||
|
.get("who"),
|
||||||
Some(&json!("ingrid")),
|
Some(&json!("ingrid")),
|
||||||
"the original session must be untouched"
|
"the original session must be untouched"
|
||||||
);
|
);
|
||||||
@@ -146,11 +200,19 @@ async fn a_session_survives_the_process_that_made_it() {
|
|||||||
- tower_sessions::cookie::time::Duration::minutes(1),
|
- tower_sessions::cookie::time::Duration::minutes(1),
|
||||||
};
|
};
|
||||||
next_boot.save(&stale).await.expect("save stale");
|
next_boot.save(&stale).await.expect("save stale");
|
||||||
assert!(next_boot.load(&stale.id).await.expect("load stale").is_none());
|
assert!(next_boot
|
||||||
|
.load(&stale.id)
|
||||||
|
.await
|
||||||
|
.expect("load stale")
|
||||||
|
.is_none());
|
||||||
|
|
||||||
// Signing out leaves nothing to read back.
|
// Signing out leaves nothing to read back.
|
||||||
next_boot.delete(&record.id).await.expect("delete");
|
next_boot.delete(&record.id).await.expect("delete");
|
||||||
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
|
assert!(next_boot
|
||||||
|
.load(&record.id)
|
||||||
|
.await
|
||||||
|
.expect("load after delete")
|
||||||
|
.is_none());
|
||||||
next_boot.delete(&collide.id).await.ok();
|
next_boot.delete(&collide.id).await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +237,20 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
|||||||
let questions = std::collections::HashMap::new();
|
let questions = std::collections::HashMap::new();
|
||||||
let access = portal::access::Policy::from_content(&questions, &aggregates);
|
let access = portal::access::Policy::from_content(&questions, &aggregates);
|
||||||
let state = portal::server::AppState {
|
let state = portal::server::AppState {
|
||||||
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
|
leptos_options: leptos::config::LeptosOptions::builder()
|
||||||
|
.output_name("test")
|
||||||
|
.build(),
|
||||||
nats,
|
nats,
|
||||||
jetstream: js,
|
jetstream: js,
|
||||||
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(questions))),
|
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||||
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(aggregates))),
|
portal::content::Storylines::one(questions),
|
||||||
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(Default::default()))),
|
)),
|
||||||
|
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)),
|
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
|
||||||
gitea_base: String::new(),
|
gitea_base: String::new(),
|
||||||
content_raw_base: String::new(),
|
content_raw_base: String::new(),
|
||||||
@@ -198,17 +268,37 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
|||||||
|
|
||||||
// A desk invites Kari by phone number; a decision grants her a
|
// A desk invites Kari by phone number; a decision grants her a
|
||||||
// second group by address. Both are invitations until she is seen.
|
// 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_phone = Identity {
|
||||||
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
|
sub: None,
|
||||||
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
|
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());
|
assert!(first.created && first.reset_link.is_none());
|
||||||
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
|
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
|
||||||
assert!(!again.created, "a second invite into the same group finds the first");
|
.await
|
||||||
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
|
.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;
|
let records = memberships::all(&state).await;
|
||||||
assert_eq!(records.len(), 2);
|
assert_eq!(records.len(), 2);
|
||||||
assert!(records.iter().all(|r| r.state == INVITED));
|
assert!(records.iter().all(|r| r.state == INVITED));
|
||||||
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
|
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.
|
// She signs in: the provider knows her subject, phone and address.
|
||||||
let kari = portal::auth::User {
|
let kari = portal::auth::User {
|
||||||
@@ -233,18 +323,47 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A desk takes her out of styret; the record stays, as removed.
|
// 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")
|
||||||
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
|
.await
|
||||||
|
.expect("revoke"));
|
||||||
|
assert!(
|
||||||
|
!memberships::revoke(&state, styret, &by_phone, "ola")
|
||||||
|
.await
|
||||||
|
.expect("revoke twice"),
|
||||||
|
"already out"
|
||||||
|
);
|
||||||
let records = memberships::all(&state).await;
|
let records = memberships::all(&state).await;
|
||||||
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
|
let styret_record = records
|
||||||
|
.iter()
|
||||||
|
.find(|r| memberships::group_of(r) == styret)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(styret_record.state, REMOVED);
|
assert_eq!(styret_record.state, REMOVED);
|
||||||
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
|
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.
|
// 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");
|
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk")
|
||||||
|
.await
|
||||||
|
.expect("re-grant");
|
||||||
assert!(back.created);
|
assert!(back.created);
|
||||||
let records = memberships::all(&state).await;
|
let records = memberships::all(&state).await;
|
||||||
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
|
assert_eq!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.find(|r| memberships::group_of(r) == styret)
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
INVITED
|
||||||
|
);
|
||||||
let groups = memberships::on_signed_in(&state, &kari).await;
|
let groups = memberships::on_signed_in(&state, &kari).await;
|
||||||
assert!(groups.contains(&styret.to_string()));
|
assert!(groups.contains(&styret.to_string()));
|
||||||
}
|
}
|
||||||
@@ -265,15 +384,74 @@ async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
|
|||||||
|
|
||||||
// Kari's first card, then two sends of her second pressed at once,
|
// Kari's first card, then two sends of her second pressed at once,
|
||||||
// and Ola's card beside them.
|
// and Ola's card beside them.
|
||||||
store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap();
|
store_answer(
|
||||||
store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap();
|
&js,
|
||||||
store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
|
&schemas,
|
||||||
store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
|
&bucket,
|
||||||
|
"k1".into(),
|
||||||
|
"/kontor/ansatt",
|
||||||
|
"a",
|
||||||
|
&card("Kari", "kari"),
|
||||||
|
now,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store_answer(
|
||||||
|
&js,
|
||||||
|
&schemas,
|
||||||
|
&bucket,
|
||||||
|
"o1".into(),
|
||||||
|
"/kontor/ansatt",
|
||||||
|
"a",
|
||||||
|
&card("Ola", "ola"),
|
||||||
|
now + 1,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store_answer(
|
||||||
|
&js,
|
||||||
|
&schemas,
|
||||||
|
&bucket,
|
||||||
|
"k2".into(),
|
||||||
|
"/kontor/ansatt",
|
||||||
|
"a",
|
||||||
|
&card("Kari N.", "kari"),
|
||||||
|
now + 2,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store_answer(
|
||||||
|
&js,
|
||||||
|
&schemas,
|
||||||
|
&bucket,
|
||||||
|
"k3".into(),
|
||||||
|
"/kontor/ansatt",
|
||||||
|
"a",
|
||||||
|
&card("Kari N.", "kari"),
|
||||||
|
now + 2,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
// Both racing sends settle after storing; both must keep the same one.
|
// Both racing sends settle after storing; both must keep the same one.
|
||||||
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
|
let (a, b) = tokio::join!(
|
||||||
|
keep_newest_own(&js, &bucket, "open", "kari"),
|
||||||
|
keep_newest_own(&js, &bucket, "open", "kari")
|
||||||
|
);
|
||||||
let _ = (a, b);
|
let _ = (a, b);
|
||||||
|
|
||||||
let kari = own_records(&js, &bucket, "kari").await;
|
let kari = own_records(&js, &bucket, "kari").await;
|
||||||
assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), vec!["k3"], "one card, the newest");
|
assert_eq!(
|
||||||
assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
|
kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["k3"],
|
||||||
|
"one card, the newest"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
own_records(&js, &bucket, "ola").await.len(),
|
||||||
|
1,
|
||||||
|
"nobody else's card is touched"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user