Compare commits

...
21 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 eb982ec6e6 A bio is markdown, on stricter rules than the site's own
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:54 +02:00
Bendik Aagaard Lynghaug c6dffc384c Release 0.6.8
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 2m0s
2026-09-30 21:08:33 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db0972974f Changelog: 0.6.8
docs / deploy (push) Successful in 4s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:30 +02:00
bl 72faf44559 Merge pull request 'An upload that did not arrive says why' (#17) from upload-messages into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 19:08:30 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db89ea6400 An upload that did not arrive says why
Test / test (pull_request) Successful in 1m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:28 +02:00
Bendik Aagaard Lynghaug 95bffc221b Release 0.6.7
Test / test (push) Successful in 1m19s
Publish release / publish (push) Successful in 2m8s
2026-09-30 20:50:44 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f5266cb347 Changelog: 0.6.7
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:42 +02:00
bl 248561e805 Merge pull request 'leaves_after: finished records leave their lists' (#16) from leaves-after into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 18:50:41 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0dc25b4023 A record may leave its lists some time after reaching a state: leaves_after
Test / test (pull_request) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:28 +02:00
Bendik Aagaard Lynghaug 96c513d912 Release 0.6.6
Test / test (push) Successful in 1m16s
Publish release / publish (push) Successful in 2m7s
2026-09-30 20:39:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0cb1dda202 Changelog: 0.6.6
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:10 +02:00
bl ec9bf10b72 Merge pull request 'A card of one's own; no double sends; refusals shown' (#15) from own-card into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
2026-09-30 18:39:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 809a6bdfe8 A card of one's own: one_per_person, prefilled, replaced on send; no double sends; refusals are shown
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:01 +02:00
Bendik Aagaard Lynghaug cae116b50c Release 0.6.5
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m54s
2026-09-30 20:10:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d8528ff1f9 Changelog: 0.6.5
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:10:56 +02:00
bl 6c2e0cc299 Merge pull request 'A need may be paused' (#14) from paused-needs into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 18:10:56 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9368086d81 A need may be paused: its card stays, with no form and a button that does not press
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:09:14 +02:00
Bendik Aagaard Lynghaug e2497941c8 Release 0.6.4
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:52:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 55fc61c514 Changelog: 0.6.4
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:52:10 +02:00
bl 3b17e6440e Merge pull request 'Rate limit: 100 at once, 10 a second; the page's own files uncounted' (#13) from rate-limit into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:52:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 87d4dda0eb Rate limit: a hundred at once, ten a second, and the page's own files are not counted
Test / test (pull_request) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:51:48 +02:00
24 changed files with 704 additions and 96 deletions
+27
View File
@@ -4,6 +4,33 @@ 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).
## Unreleased
- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
## 0.6.8 (2026-09-30)
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
## 0.6.7 (2026-09-30)
- `leaves_after: { minutes: 10 }` on a state: a record that has been there that long leaves its bucket's lists (days, hours and minutes add up; swept every minute; the event log keeps it). Trainer: `leaves_after` per stage on a need. A Klingenberg Bygg card taken off the front page leaves the office's list ten minutes later instead of standing there greyed out.
## 0.6.6 (2026-09-30)
- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page.
- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice.
- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not.
- A refused send says why, under its button. It used to say nothing.
## 0.6.5 (2026-09-30)
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
## 0.6.4 (2026-09-30)
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
## 0.6.3 (2026-09-30) ## 0.6.3 (2026-09-30)
- Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before. - Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.6.3" version = "0.6.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.6.3" version = "0.6.8"
edition = "2021" edition = "2021"
[lib] [lib]
+4 -1
View File
@@ -275,7 +275,10 @@ time. The cards appear in the order the records were put up. That is
how a site shows the people who work there, or a gallery of what it how a site shows the people who work there, or a gallery of what it
has built. On a desk, the same picture is a thumbnail that opens. has built. On a desk, the same picture is a thumbnail that opens.
`accept: image/jpeg,image/png,image/webp` on the field makes a phone `accept: image/jpeg,image/png,image/webp` on the field makes a phone
send a JPEG whatever it keeps its photos as. send a JPEG whatever it keeps its photos as. What the person wrote is shown as
markdown on stricter rules than the site's own: paragraphs, line
breaks as typed, lists, emphasis and links to https, mailto or tel
(marked `nofollow ugc`); no images, no raw HTML.
`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
+16 -7
View File
@@ -82,22 +82,31 @@ values.
| `GARAGE_*` | uploads | | `GARAGE_*` | uploads |
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off | | `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed | | `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below | | `PORTAL_RATE_BURST`, `PORTAL_RATE_PER_SECOND` | the rate limit; see below |
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
separate unit on the same host reading the same NATS. separate unit on the same host reading the same NATS.
## Rate limiting ## Rate limiting
Every request passes a per-caller-IP limit before a session is loaded Every request that makes portal do something passes a per-caller-IP
or a handler is entered: a burst of 30, refilled one every two limit before a session is loaded or a handler is entered: a burst of
seconds. A visitor filling in a form never reaches it (a page load 100, refilled at ten a second. More than a hundred requests in ten
fetches several resources at once); a script does. seconds is a script; someone reloading a page again and again stays
well inside it.
The files a page is made of, served from portal's own disk, are not
counted: the stylesheet and scripts under `/pkg`, the fonts, the
favicons, the vendored libraries. Refusing one of those is what turns
a reloaded page into an unstyled mess, and serving one costs next to
nothing. Anything that makes portal ask someone else is counted: a
content picture under `/site/` is fetched from Gitea, a map tile from
the map provider.
| | | | | |
|---|---| |---|---|
| `PORTAL_RATE_BURST` | how many requests at once, default 30 | | `PORTAL_RATE_BURST` | how many requests at once, default 100 |
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 | | `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
Either set to `0` turns it off, for an instance behind something that Either set to `0` turns it off, for an instance behind something that
already does this. The caller is read from `X-Forwarded-For` or already does this. The caller is read from `X-Forwarded-For` or
+16
View File
@@ -116,6 +116,22 @@ the same transition path, fires the same mail, and lands on
The unit is days and the sweep is hourly. This is a deadline, not a The unit is days and the sweep is hourly. This is a deadline, not a
job queue. job queue.
### leaves_after
```yaml
taken_down:
event: taken_down
leaves_after: { minutes: 10 }
```
How long a record stays in its bucket's lists once it has entered the
state, when that is not for ever. A card taken off the front page
leaves the desk's list ten minutes later instead of standing there
greyed out, and the ten minutes are the time to see what was just
done. Days, hours and minutes add up, and a sweep takes records out
once a minute. Nothing is decided and no mail goes. The record leaves
the lists, and the event log keeps it.
## attended_by ## attended_by
```yaml ```yaml
+8 -3
View File
@@ -100,7 +100,9 @@ needs:
- { name: oppstart, type: date, optional: true } - { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true } - { name: skisser, type: file, optional: true }
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
- id: soknad - id: soknad
paused: true
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
wants: en godkjent byggesøknad, med ansvarsrett der det kreves wants: en godkjent byggesøknad, med ansvarsrett der det kreves
lands_in: soknader lands_in: soknader
@@ -143,12 +145,15 @@ needs:
- { name: hva } - { name: hva }
- { name: dokument, type: file } - { name: dokument, type: file }
# Menneskene: kontoret legger inn hver ansatt med navn, rolle, # Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle,
# erfaring og portrett, og forsiden viser dem alle. # erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned.
- id: ansatt - id: ansatt
who: kontoret, når noen begynner hos oss eller skal ha nytt portrett who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan
wants: at den som vurderer oss ser hvem som gjør jobben wants: at den som vurderer oss ser hvem som gjør jobben
enters_as: kontor enters_as: kontor
one_per_person: true
# Et kort som er tatt ned, forsvinner fra kontorets liste etter ti minutter.
leaves_after: { sluttet: { minutes: 10 } }
lands_in: ansatte lands_in: ansatte
handled_by: kontor handled_by: kontor
watched_by: [public] watched_by: [public]
+21 -20
View File
@@ -109,7 +109,8 @@
"telefon": "Telefonnummer", "telefon": "Telefonnummer",
"adresse": "Adresse", "adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…" "tiltak": "Tilbygg, ombygging, nybygg…"
} },
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
} }
}, },
"who": { "who": {
@@ -257,7 +258,7 @@
}, },
"ansatte": { "ansatte": {
"heading": "Ansatte på forsiden", "heading": "Ansatte på forsiden",
"description": "Menneskene slik besøkende ser dem.", "description": "Menneskene slik besøkende ser dem. Hver retter sitt eget kort; herfra kan et kort tas ned.",
"moves": { "moves": {
"open->sluttet": "Tatt av forsiden" "open->sluttet": "Tatt av forsiden"
}, },
@@ -272,8 +273,8 @@
"button": "Ny leveranse" "button": "Ny leveranse"
}, },
"ansatt": { "ansatt": {
"heading": "Legg inn en ansatt", "heading": "Ditt kort på forsiden",
"description": "Navn, rolle, erfaring og portrett, rett ut på forsiden.", "description": "Rolle, erfaring og portrett, slik besøkende ser deg.",
"button": "Åpne" "button": "Åpne"
} }
}, },
@@ -470,10 +471,10 @@
} }
}, },
"questions/kontor/ansatt-sent.yaml": { "questions/kontor/ansatt-sent.yaml": {
"question": "Lagt ut", "question": "Kortet ditt er lagret",
"description": "Den ansatte vises nå blant menneskene på forsiden.", "description": "Slik står du nå på forsiden.",
"next_heading": "Skal noen ned igjen?", "next_heading": "Vil du endre noe?",
"next_description": "Trykk «Tatt av forsiden» på raden deres i listen over ansatte på kontoret. Nytt portrett: legg personen inn på nytt, og ta den gamle raden av.", "next_description": "Åpne «Ditt kort på forsiden» igjen: det er fylt ut med det du har, og det du lagrer erstatter det.",
"see_all": { "see_all": {
"heading": "Se alle ansatte", "heading": "Se alle ansatte",
"description": "", "description": "",
@@ -481,21 +482,21 @@
} }
}, },
"questions/kontor/ansatt.yaml": { "questions/kontor/ansatt.yaml": {
"heading": "Legg inn en ansatt", "heading": "Ditt kort på forsiden",
"description": "Navn, rolle, erfaring og portrett. Den ansatte vises på forsiden så snart du sender.", "description": "Slik står du blant menneskene på forsiden. Rett det du vil og lagre; du har ett kort, og det nye erstatter det gamle.",
"button": "Legg ut", "button": "Lagre kortet",
"encouragement": "", "encouragement": "",
"icon": "user-plus", "icon": "id-card",
"requires_title": "", "requires_title": "",
"requires_description": "", "requires_description": "",
"requires_icon": "", "requires_icon": "",
"road_title": "Hva skjer så", "road_title": "Når du lagrer",
"road_description": "Personen vises på forsiden til du tar dem av igjen fra kontoret.", "road_description": "Kortet ditt på forsiden endres med en gang.",
"road_icon": "route", "road_icon": "route",
"who_title": "Hvem ser det", "who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Portrettet vises som det er, så velg et bilde du er fornøyd med.", "who_description": "Alle som besøker siden. Navnet ditt er det du er logget inn med.",
"who_icon": "users", "who_icon": "users",
"form_title": "Den ansatte", "form_title": "Kortet ditt",
"form_description": "", "form_description": "",
"labels": { "labels": {
"name": "Navn", "name": "Navn",
@@ -506,11 +507,11 @@
"hints": { "hints": {
"name": "", "name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør", "rolle": "For eksempel daglig leder, rådgivende ingeniør",
"erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av", "erfaring": "Utdanning, fagområder, bygg du er stolt av. Du kan skrive **fet**, *kursiv*, lister med - og [lenker](https://…).",
"portrett": "Et stående bilde, gjerne tatt i dagslys" "portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
}, },
"question": "Hvem skal vises på forsiden?", "question": "Ditt kort på forsiden",
"page_description": "" "page_description": "Det du skriver her, står under portrettet ditt på forsiden."
}, },
"questions/status/ansatte/index.yaml": { "questions/status/ansatte/index.yaml": {
"question": "Hvem består Klingenberg Bygg av?", "question": "Hvem består Klingenberg Bygg av?",
+39
View File
@@ -51,6 +51,35 @@ pub struct AggregateSchema {
/// no answer in six weeks has to go on without one, and nothing in /// no answer in six weeks has to go on without one, and nothing in
/// a building is going to press that button. /// a building is going to press that button.
pub after_for_state: HashMap<String, AfterSpec>, pub after_for_state: HashMap<String, AfterSpec>,
/// How long a record stays in the list once it has entered a
/// state, when that is not for ever: a card taken off the front
/// page leaves the desk's list a little later, instead of standing
/// there greyed out. The event log keeps it.
pub leaves_for_state: HashMap<String, LeavesSpec>,
}
/// `leaves_after: { minutes: 10 }` on a state: how long a record that
/// has entered it stays in its bucket's list. Days, hours and minutes
/// add up; the sweep that takes it out runs every minute.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LeavesSpec {
#[serde(default, skip_serializing_if = "is_zero")]
pub days: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub hours: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub minutes: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
impl LeavesSpec {
pub fn millis(&self) -> i64 {
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
}
} }
/// A mail to send when a record enters a state. `to` is `submitter` /// A mail to send when a record enters a state. `to` is `submitter`
@@ -125,6 +154,8 @@ struct RawState {
grants: Option<String>, grants: Option<String>,
#[serde(default)] #[serde(default)]
after: Option<AfterSpec>, after: Option<AfterSpec>,
#[serde(default)]
leaves_after: Option<LeavesSpec>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@@ -197,7 +228,14 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
let mut mail_for_state = HashMap::new(); let mut mail_for_state = HashMap::new();
let mut grants_for_state = HashMap::new(); let mut grants_for_state = HashMap::new();
let mut after_for_state = HashMap::new(); let mut after_for_state = HashMap::new();
let mut leaves_for_state = HashMap::new();
for (state, raw_state) in raw_schema.states { for (state, raw_state) in raw_schema.states {
if let Some(leaves) = raw_state.leaves_after {
if leaves.millis() == 0 {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: leaves_after needs some days, hours or minutes");
}
leaves_for_state.insert(state.clone(), leaves);
}
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");
@@ -245,6 +283,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
mail_for_state, mail_for_state,
grants_for_state, grants_for_state,
after_for_state, after_for_state,
leaves_for_state,
}, },
); );
} }
+67
View File
@@ -114,6 +114,73 @@ pub async fn store_answer(
Ok(()) Ok(())
} }
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { 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) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// 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.
#[cfg(feature = "ssr")]
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 Ok(store) = js.get_key_value(bucket).await else { return };
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
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 };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
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" {
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" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// One selected row's pending transition. `feature_name` is per-item /// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same /// so a batch can span more than one resource feature on the same
/// alternative. /// alternative.
+129 -2
View File
@@ -1198,9 +1198,11 @@ fn AlternativeCard(
if req.kind == "file" { if req.kind == "file" {
file_refs.entry(req.name.clone()).or_default(); file_refs.entry(req.name.clone()).or_default();
} else if req.kind == "select" { } else if req.kind == "select" {
// Preset by a card of one's own (`answers::fill_own`).
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
select_field_map select_field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(preset));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
// Widget-owned fields hold JSON objects, not strings. // Widget-owned fields hold JSON objects, not strings.
@@ -1238,6 +1240,11 @@ fn AlternativeCard(
let gesture_fields_for_submit = gesture_fields.clone(); let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone(); let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone(); let alt_name_for_transition = alternative.name.clone();
// From the moment Send is pressed until the server has answered:
// files upload first, and a second press in that time used to send
// the form twice.
let sending = RwSignal::new(false);
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
@@ -1266,6 +1273,8 @@ fn AlternativeCard(
let question_id_for_submit = question_id_for_submit.clone(); let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone();
sending.set(true);
send_error.set(None);
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() { for (name, sig) in field_map_for_submit.iter() {
@@ -1301,12 +1310,25 @@ fn AlternativeCard(
}; };
let Some(files) = input.files() else { continue }; let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue }; let Some(file) = files.get(0) else { continue };
// Too big is known before a byte is sent.
if file.size() > MAX_UPLOAD_BYTES as f64 {
send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string()));
sending.set(false);
return;
}
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await { match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
Ok(key) => { Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key)); map.insert(name.clone(), serde_json::Value::String(key));
} }
// A form sent without the file it asked for is a
// record that looks complete and is not: stop, and
// say so.
Err(e) => { Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}"); leptos::logging::error!("upload of {name} failed: {e:?}");
let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok()));
send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string()));
sending.set(false);
return;
} }
} }
} }
@@ -1319,6 +1341,7 @@ fn AlternativeCard(
parent_hash: parent_hash_for_submit, parent_hash: parent_hash_for_submit,
responses_json, responses_json,
}); });
sending.set(false);
}); });
}; };
@@ -1512,9 +1535,26 @@ fn AlternativeCard(
if req.kind == "file" { if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new); let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
// On a card of one's own, the file already
// there (see `answers::fill_own`).
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
view! {
<span class="field-current">
<span>{move || t(&lang().get(), "current_file")}</span>
{if picture {
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
} else {
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
}}
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
</span>
}
});
return view! { return view! {
<label class="field" for=label_for> <label class="field" for=label_for>
{label_text} {label_text}
{current}
<input <input
id=field_id id=field_id
type="file" type="file"
@@ -1826,12 +1866,24 @@ fn AlternativeCard(
class="alt-submit" class="alt-submit"
disabled=move || { disabled=move || {
(!has_requirements && pending_transitions.get().is_empty()) (!has_requirements && pending_transitions.get().is_empty())
|| sending.get()
|| submit.pending().get() || submit.pending().get()
|| transition_batch.pending().get() || transition_batch.pending().get()
} }
> >
{button_label.clone()} {button_label.clone()}
</button> </button>
// Why nothing happened, when something
// was refused: it used to say nothing.
{move || {
send_error
.get()
.or_else(|| match submit.value().get() {
Some(Err(e)) => Some(server_message(&e)),
_ => None,
})
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
}}
</form> </form>
} }
.into_any() .into_any()
@@ -2238,6 +2290,31 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty()) rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
} }
/// The largest file one upload may carry. Here, not in `upload.rs`,
/// so the browser checks the same number before it sends anything.
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
/// Why an upload did not arrive, in words a person can act on, from
/// the status `POST /upload` answered with (see `upload.rs`): too big,
/// a site that takes no files, a page they may not send from, or
/// anything else.
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
fn upload_refusal(status: Option<u16>) -> &'static str {
match status {
Some(413) => "upload_too_large",
Some(503) => "upload_unavailable",
Some(403) => "upload_forbidden",
_ => "upload_failed",
}
}
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
let text = e.to_string();
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
}
/// Whether an uploaded file is a picture a browser shows as it is. /// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool { pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase(); let lower = filename.to_ascii_lowercase();
@@ -2476,7 +2553,12 @@ fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl In
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" /> <img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text"> <div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4> <h4 class="answer-card-heading">{heading}</h4>
{rest.into_iter().map(|t| view! { <p>{t}</p> }).collect_view()} // What the person wrote, as they wrote it: markdown, on
// the stricter rules for text that is not the site's.
{rest
.into_iter()
.map(|t| view! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> })
.collect_view()}
</div> </div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })} {link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article> </article>
@@ -2559,6 +2641,7 @@ pub async fn get_question(
let session: tower_sessions::Session = leptos_axum::extract().await?; let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?; let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref()); fill_from_user(&mut question, user.as_ref());
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
let mut chain_gate = None; let mut chain_gate = None;
if let Some(target) = &question.requires_chain { if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required // The claimed lineage must verifiably end at the required
@@ -2912,6 +2995,26 @@ pub async fn submit_answer(
let mut responses: serde_json::Value = serde_json::from_str(&responses_json) let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?; .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// One card each: whose it is comes from the session, and a file the
// person did not choose again is the one already on their card.
let owner = if alt.one_per_person {
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
if let Some(map) = responses.as_object_mut() {
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
map.insert(req.name.clone(), kept.clone());
}
}
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
}
Some((bucket, user.username.clone()))
} else {
None
};
// A record that belongs to another record: check the other one is // A record that belongs to another record: check the other one is
// there before this one is written, so a reference in a stored // there before this one is written, so a reference in a stored
// record is one that resolved at least once. An optional field // record is one that resolved at least once. An optional field
@@ -3027,6 +3130,15 @@ pub async fn submit_answer(
{ {
tracing::error!("failed to store answer in {bucket}: {e}"); tracing::error!("failed to store answer in {bucket}: {e}");
} }
if let Some((own_bucket, username)) = &owner {
let initial = state
.aggregates
.load()
.get(own_bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
}
// Arrival is a state too: what the schema says the first // Arrival is a state too: what the schema says the first
// state sends, it sends now. // state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) { if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
@@ -3159,6 +3271,21 @@ mod responsible_tests {
mod picture_tests { mod picture_tests {
use super::*; use super::*;
#[test]
fn an_upload_refusal_says_which_it_was() {
assert_eq!(upload_refusal(Some(413)), "upload_too_large");
assert_eq!(upload_refusal(Some(503)), "upload_unavailable");
assert_eq!(upload_refusal(Some(403)), "upload_forbidden");
assert_eq!(upload_refusal(Some(502)), "upload_failed");
assert_eq!(upload_refusal(None), "upload_failed");
for lang in ["en", "no"] {
for status in [Some(413), Some(503), Some(403), None] {
let key = upload_refusal(status);
assert_ne!(crate::i18n::t(lang, key), key, "{lang} has words for {key}");
}
}
}
#[test] #[test]
fn a_record_with_a_picture_is_shown_by_it() { fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf")); assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
+64
View File
@@ -118,3 +118,67 @@ 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
/// opposed to the site's own content. Paragraphs, line breaks as they
/// were typed, lists, emphasis and links; no images (a picture has its
/// own field, and an image in text is a way to load something from
/// elsewhere into every visitor's page), no raw HTML, headings as plain
/// paragraphs. Links go to https, mailto or tel only, and say they are
/// the writer's, not the site's (`rel="nofollow ugc"`).
pub fn render_written_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
let mut in_dropped_link = 0usize;
let mut in_image = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Image { .. }) => {
in_image += 1;
None
}
Event::End(TagEnd::Image) => {
in_image = in_image.saturating_sub(1);
None
}
_ if in_image > 0 => None,
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
// What someone typed on a new line is on a new line.
Event::SoftBreak => Some(Event::HardBreak),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
{
in_dropped_link += 1;
None
}
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
in_dropped_link -= 1;
None
}
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
}
#[cfg(test)]
mod written_tests {
use super::render_written_markdown as md;
#[test]
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!(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_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.
assert_eq!(md("<script>alert(1)</script>"), "");
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("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
assert_eq!(md("before ![pixel](https://tracker.example/p.gif) after"), "<p>before after</p>");
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
}
}
+8
View File
@@ -343,6 +343,14 @@ pub struct Alternative {
/// works (lysbue had the same flag). /// works (lysbue had the same flag).
#[serde(default)] #[serde(default)]
pub disabled: bool, pub disabled: bool,
/// Each signed-in person keeps one record here: their own, which
/// sending again replaces. The form opens with what their record
/// says, a file left empty keeps the one already there, and the
/// older record leaves the list. For what a person says about
/// themselves - a card on the front page, a profile. Needs
/// `record_as`, and someone signed in.
#[serde(default)]
pub one_per_person: bool,
#[serde(default)] #[serde(default)]
pub consequence: Vec<String>, pub consequence: Vec<String>,
#[serde(default)] #[serde(default)]
+7
View File
@@ -512,6 +512,13 @@ pub fn validate_questions(
} }
for question in questions.values() { for question in questions.values() {
for alternative in &question.alternatives { for alternative in &question.alternatives {
if alternative.one_per_person && alternative.record_as.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
question.id,
alternative.name
);
}
if let Some(bucket) = &alternative.record_as { if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket) let attended = read_buckets.contains(bucket)
|| aggregates || aggregates
+64
View File
@@ -37,6 +37,55 @@ pub async fn run(state: AppState) {
} }
} }
/// Records leaving their list: once a record has been in a state with
/// `leaves_after` for that long, it is taken out of the bucket. Not a
/// move - nothing is decided, no mail goes - and the event log keeps
/// the record. Every minute, over the buckets that use it and no
/// others.
pub async fn run_leaving(state: AppState) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
if let Err(e) = sweep_leaving(&state).await {
tracing::warn!(error = %e, "leaving sweep failed");
}
}
}
/// Whether a record has stayed out its time in the state it is in.
pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> bool {
schema
.leaves_for_state
.get(&answer.state)
.is_some_and(|leaves| now_ms >= waiting_since(answer) + leaves.millis())
}
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let aggregates = state.aggregates.load();
for schema in aggregates.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> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => continue,
}
};
for id in keys {
let Ok(Some(bytes)) = store.get(&id).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if has_left(schema, &answer, now_ms) {
match store.delete(&id).await {
Ok(()) => 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}"),
}
}
}
}
Ok(())
}
/// When a record entered the state it is in now. A record nobody has /// When a record entered the state it is in now. A record nobody has
/// moved has been waiting since it was sent. /// moved has been waiting since it was sent.
fn waiting_since(answer: &Answer) -> i64 { fn waiting_since(answer: &Answer) -> i64 {
@@ -150,6 +199,21 @@ aggregates:
} }
} }
#[test]
fn a_record_leaves_its_list_when_its_time_in_the_state_is_up() {
let s = schema(
"aggregates:\n - bucket: people\n initial: open\n states:\n open: { event: shown }\n gone: { event: gone, leaves_after: { minutes: 10 } }\n transitions:\n open: [gone]\n",
);
let t = 1_000_000_000_000;
let mut answer = waiting("gone", t - 60 * 60 * 1000);
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 + 10 * 60 * 1000));
// A state without it keeps its records.
assert!(!has_left(&s, &waiting("open", 0), t));
assert!(crate::aggregates::parse_aggregates_yaml("aggregates:\n - bucket: p\n initial: open\n states:\n open: { event: o, leaves_after: {} }\n transitions: {}\n").is_err());
}
#[test] #[test]
fn a_deadline_comes_due_the_day_it_says_and_not_before() { fn a_deadline_comes_due_the_day_it_says_and_not_before() {
let s = schema(HEARINGS); let s = schema(HEARINGS);
+18
View File
@@ -24,6 +24,12 @@ 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", "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_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
("en", "upload_forbidden") => "Files cannot be sent from here by you, so nothing was sent. Are you signed in?",
("en", "current_file") => "Now:",
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
("en", "nothing_here") => "Nothing here", ("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start", ("en", "back_to_start") => "back to the start",
("en", "done") => "Done.", ("en", "done") => "Done.",
@@ -54,6 +60,12 @@ 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", "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_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
("no", "upload_forbidden") => "Du kan ikke sende filer herfra, så ingenting ble sendt. Er du logget inn?",
("no", "current_file") => "Nå:",
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
("no", "nothing_here") => "Ingenting her", ("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start", ("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.", ("no", "done") => "Ferdig.",
@@ -102,6 +114,12 @@ mod tests {
"sign_in", "sign_in",
"sign_out", "sign_out",
"nothing_here_yet", "nothing_here_yet",
"upload_failed",
"upload_too_large",
"upload_unavailable",
"upload_forbidden",
"current_file",
"keep_file",
"nothing_here", "nothing_here",
"back_to_start", "back_to_start",
"done", "done",
+5 -3
View File
@@ -180,11 +180,13 @@ fn check_needs(
} }
} }
if failed == 0 { if failed == 0 {
let planned = file.needs.iter().filter(|n| n.planned).count(); let planned = file.needs.iter().filter(|n| n.planned && !n.paused).count();
let paused = file.needs.iter().filter(|n| n.paused).count();
println!( println!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)", "OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
file.needs.len() - planned, file.needs.len() - planned - paused,
planned, planned,
paused,
file.personas.len() file.personas.len()
); );
} }
+9 -5
View File
@@ -78,6 +78,10 @@ pub struct Need {
/// as a warning, so the gap stays visible without failing CI. /// as a warning, so the gap stays visible without failing CI.
#[serde(default)] #[serde(default)]
pub planned: bool, pub planned: bool,
/// Open before and closed for now (full up, out of season): its
/// card is on the page with no form. Reports like `planned`.
#[serde(default)]
pub paused: bool,
/// Each state of the bucket in plain words, in the site's /// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian /// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model. /// `ikke_lost` reads as English to a model.
@@ -305,11 +309,11 @@ pub fn check(
let mut report = |level: Level, message: String| { let mut report = |level: Level, message: String| {
findings.push(Finding { findings.push(Finding {
need: need.id.clone(), need: need.id.clone(),
level: if need.planned { Level::Warn } else { level }, level: if need.planned || need.paused { Level::Warn } else { level },
message: if need.planned && level == Level::Fail { message: match (need.paused, need.planned, level == Level::Fail) {
format!("planned, not met yet: {message}") (true, _, true) => format!("paused, not open now: {message}"),
} else { (false, true, true) => format!("planned, not met yet: {message}"),
message _ => message,
}, },
}) })
}; };
+55 -44
View File
@@ -132,6 +132,7 @@ async fn serve() -> anyhow::Result<()> {
// windows (see announce.rs). // windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone())); tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone())); tokio::spawn(portal::deadlines::run(state.clone()));
tokio::spawn(portal::deadlines::run_leaving(state.clone()));
// The accounts the content mandates: the first person in, and // The accounts the content mandates: the first person in, and
// everyone a page names as responsible, at start and whenever the // everyone a page names as responsible, at start and whenever the
// content changes (seed.rs). // content changes (seed.rs).
@@ -166,30 +167,32 @@ async fn serve() -> anyhow::Result<()> {
.with_name("portal_session"); .with_name("portal_session");
// What a person does costs a little; what a script does costs a // What a person does costs a little; what a script does costs a
// lot. A burst of 30 with one refilled every two seconds is well // lot. More than a hundred requests in ten seconds is a script: a
// above anything a visitor filling in a form will reach - a page // burst of 100, refilled at ten a second. Someone reloading a page
// load fetches several resources at once - and well below what it // again and again stays well inside it, the more so since the
// takes to be a nuisance. Per caller IP, in memory, no store: this // files a page is made of (stylesheet, scripts, fonts, pictures)
// is a fence, not an accounting system, and the fence that matters // are not counted at all - see `assets` below. Per caller IP, in
// for uploads is the policy check on the route itself. // memory, no store: this is a fence, not an accounting system, and
// the fence that matters for uploads is the policy check on the
// route itself.
// //
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site; // PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
// 0 for either turns it off, for an instance behind something that // 0 for either turns it off, for an instance behind something that
// already does this. // already does this.
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS") let rate_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(2); .unwrap_or(10);
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST") let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(30); .unwrap_or(100);
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| { let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is // SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
// what every one of these instances is behind; it falls back to // what every one of these instances is behind; it falls back to
// the socket address when neither is set. // the socket address when neither is set.
let conf = tower_governor::governor::GovernorConfigBuilder::default() let conf = tower_governor::governor::GovernorConfigBuilder::default()
.per_second(rate_seconds) .per_millisecond((1000 / rate_per_second).max(1))
.burst_size(rate_burst) .burst_size(rate_burst)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor) .key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish() .finish()
@@ -197,8 +200,8 @@ async fn serve() -> anyhow::Result<()> {
std::sync::Arc::new(conf) std::sync::Arc::new(conf)
}); });
match &governor { match &governor {
Some(_) => tracing::info!(rate_seconds, 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_SECONDS 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(
@@ -233,34 +236,13 @@ async fn serve() -> anyhow::Result<()> {
let fonts_dir = format!("{}/fonts", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let page_motion = format!("{}/page-motion.js", leptos_options.site_root); let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
let app = Router::new() // The files a page is made of, from this disk: the stylesheet, the
.route("/auth/login", get(oidc::login)) // scripts, the fonts. Not counted by the rate limit - they cost
.route("/auth/callback", get(oidc::callback)) // next to nothing, and refusing one is what turns a reloaded page
.route("/auth/logout", get(oidc::logout)) // into an unstyled mess - and no session is loaded for them.
.route("/api/{*fn_name}", any(server_fn_handler)) // Anything that makes portal ask someone else (content pictures
// axum caps request bodies at 2 MB by default, which had made the // from Gitea, map tiles) stays below, counted.
// 20 MB upload limit unreachable; raise the fence on this route only, let assets: Router<AppState> = Router::new()
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
)
.route(
"/attachment/{bucket}/{record}/{field}",
get(upload::attachment),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// What the storylines did, as counts (report.rs).
.route("/report", get(portal::report::handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
// Maps, proxied: the browser asks this origin for every tile
// and every still, so nothing third-party loads in a page and
// the token stays here (see maps.rs).
.route("/map/style.json", get(portal::maps::style))
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
.route("/map/still/{place}/{size}", get(portal::maps::still))
// no-cache = "revalidate before reuse", not "don't cache": // no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js, // pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache // portal.wasm), and without this browsers heuristically cache
@@ -296,7 +278,36 @@ async fn serve() -> anyhow::Result<()> {
.service(ServeFile::new(page_motion)), .service(ServeFile::new(page_motion)),
) )
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js)) .route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css)) .route_service("/maplibre-gl.css", ServeFile::new(maplibre_css));
let app = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
// axum caps request bodies at 2 MB by default, which had made the
// 20 MB upload limit unreachable; raise the fence on this route only,
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
)
.route(
"/attachment/{bucket}/{record}/{field}",
get(upload::attachment),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// What the storylines did, as counts (report.rs).
.route("/report", get(portal::report::handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
// Maps, proxied: the browser asks this origin for every tile
// and every still, so nothing third-party loads in a page and
// the token stays here (see maps.rs).
.route("/map/style.json", get(portal::maps::style))
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
.route("/map/still/{place}/{size}", get(portal::maps::still))
.leptos_routes_with_context( .leptos_routes_with_context(
&state, &state,
routes, routes,
@@ -318,8 +329,8 @@ async fn serve() -> anyhow::Result<()> {
let app = match governor { let app = match governor {
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)), Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
None => app, None => app,
} };
.with_state(state); let app = assets.merge(app).with_state(state);
tracing::info!("listening on http://{addr}"); tracing::info!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
+57 -4
View File
@@ -425,6 +425,27 @@ pub struct PlanNeed {
/// picture. /// picture.
#[serde(default)] #[serde(default)]
pub presents: bool, pub presents: bool,
/// Not taking new cases for now: full up, a season closed. The
/// card stays where it is, saying what the business does and that
/// it is paused, with no form to fill in and a button that does not
/// press; the server refuses a submission. Remove the line to open
/// again - the desk, the stages and every case under way are
/// untouched either way.
#[serde(default)]
pub paused: bool,
/// Each person in the group that enters it keeps one record: their
/// own card, which they open filled in and send again to change.
/// For what people say about themselves - who they are on the
/// front page. Needs `enters_as`, so there is someone signed in to
/// own it.
#[serde(default)]
pub one_per_person: bool,
/// How long a record stays in the lists once it reaches a stage,
/// when not for ever: `{ sluttet: { minutes: 10 } }` takes a card
/// off the desk's list ten minutes after it came off the front
/// page, instead of leaving it there greyed out.
#[serde(default)]
pub leaves_after: BTreeMap<String, crate::aggregates::LeavesSpec>,
pub collects: Vec<Field>, pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the /// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask. /// business said about them, not what the form happens to ask.
@@ -748,6 +769,15 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to))); bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
} }
} }
if let Some(bad) = need.leaves_after.keys().find(|st| !need.stages.contains(st)) {
bail!(at(&format!("leaves_after names {bad:?}, which is not one of its stages")));
}
if need.leaves_after.values().any(|l| l.millis() == 0) {
bail!(at("leaves_after needs some days, hours or minutes"));
}
if need.one_per_person && need.enters_as.is_none() {
bail!(at("one_per_person keeps a card for each signed-in person, and the need has no enters_as to sign them in"));
}
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) { if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by")); bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
} }
@@ -1082,6 +1112,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"button": "Send", "button": "Send",
"encouragement": "", "encouragement": "",
"icon": "", "icon": "",
// Said after the description, on a paused need's card only.
"paused": if need.paused { loc(lang, "We are not taking new ones right now, and open again as soon as we can.", "Akkurat nå tar vi ikke imot nye; vi åpner igjen så snart vi kan.") } else { String::new() },
// Three sections of context the plan already knows: what // Three sections of context the plan already knows: what
// the person must have for this to work, the road a case // the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are // travels, and who is at the other end. The fallbacks are
@@ -1957,15 +1989,32 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if !s(&w["who_description"]).trim().is_empty() { if !s(&w["who_description"]).trim().is_empty() {
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"]))); features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
} }
features.push(feature); // A paused need asks for nothing: a form nobody can send has
// already wasted the time it took to fill in.
if !need.paused {
features.push(feature);
}
let description = if need.paused {
text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string())
} else {
text(&w["description"])
};
let mut alt = json!({ let mut alt = json!({
"name": text(&w["heading"]), "name": text(&w["heading"]),
"description": text(&w["description"]), "description": description,
"action": action, "action": action,
"consequence": button(&w["button"]), "consequence": button(&w["button"]),
"record_as": need.lands_in, "record_as": need.lands_in,
"features": features, "features": features,
}); });
if need.one_per_person {
alt["one_per_person"] = json!(true);
}
// Paused, it records nothing and says where nothing goes.
if need.paused {
alt["disabled"] = json!(true);
alt.as_object_mut().map(|a| a.remove("record_as"));
}
if !s(&w["encouragement"]).trim().is_empty() { if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = button(&w["encouragement"]); alt["encouragements"] = button(&w["encouragement"]);
} }
@@ -2022,6 +2071,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if let Some(d) = need.deadlines.get(st) { if let Some(d) = need.deadlines.get(st) {
state["after"] = json!({ "days": d.days, "to": d.to }); state["after"] = json!({ "days": d.days, "to": d.to });
} }
if let Some(leaves) = need.leaves_after.get(st) {
state["leaves_after"] = json!(leaves);
}
(st.clone(), state) (st.clone(), state)
}).collect::<Map<_, _>>(), }).collect::<Map<_, _>>(),
"transitions": machine(need), "transitions": machine(need),
@@ -2452,6 +2504,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(), "stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
}); });
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); } if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
if n.paused { need["paused"] = json!(true); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect(); let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
if !others.is_empty() { need["also_moved_by"] = json!(others); } if !others.is_empty() { need["also_moved_by"] = json!(others); }
need need
@@ -2525,7 +2578,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
context.extend( context.extend(
plan.needs plan.needs
.iter() .iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))), .map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, match (n.paused, n.one_per_person) { (true, _) => " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)", (_, true) => " (ONE EACH: every signed-in person keeps one record here, their own; the form opens filled in with it and sending saves the change, so it speaks to them about their own and its button saves)", _ => "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
); );
format!( format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.", "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
@@ -2682,7 +2735,7 @@ mod tests {
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![], moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![], events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: false, leaves_after: BTreeMap::new(),
collects: vec![], max_fields: 3, collects: vec![], max_fields: 3,
} }
} }
+15
View File
@@ -191,6 +191,21 @@ rest. Each is one line in the plan, and the builder does the wiring:
of them, as cards: the people who work there, entered from inside of them, as cards: the people who work there, entered from inside
(`enters_as`), each with a `picture` field that shows as their (`enters_as`), each with a `picture` field that shows as their
portrait. portrait.
- **Let people keep their own.** `one_per_person: true` on a need
entered from inside gives each signed-in person one record there:
their own, which the form opens with and which sending again
replaces - a card on the front page they edit themselves. A picture
left unchosen stays as it was.
- **Let finished things go.** `leaves_after: { <stage>: { minutes: 10 } }`
takes a record out of every list that long after it reaches that
stage (days, hours and minutes add up). A card taken off the front
page need not stand greyed out on the desk for ever; the history
keeps it.
- **Pause what you cannot take on.** `paused: true` on a need keeps
its card on the page, saying what the business does and that it is
not taking new ones right now, with no form and a button that does
not press. Take the line out to open again; the desk and every case
under way are untouched.
- **Show the work.** `slideshow` on the plan is portal's own block, - **Show the work.** `slideshow` on the plan is portal's own block,
carried onto the front door as written: a strip of pictures under carried onto the front door as written: a strip of pictures under
the question, each with its caption. the question, each with its caption.
+1 -1
View File
@@ -15,7 +15,7 @@ use serde::Serialize;
use crate::server::AppState; use crate::server::AppState;
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024; pub const MAX_UPLOAD_BYTES: usize = crate::app::MAX_UPLOAD_BYTES;
#[derive(Clone)] #[derive(Clone)]
pub struct Garage { pub struct Garage {
+43 -4
View File
@@ -1300,14 +1300,36 @@ textarea:focus {
font-size: 1.05rem; font-size: 1.05rem;
} }
.answer-card-text p { .card-field {
margin: 0;
font-size: 0.92rem; font-size: 0.92rem;
color: var(--ink-dim); color: var(--ink-dim);
white-space: pre-line;
} }
.answer-card-text p:first-of-type { .card-field > :first-child {
margin-top: 0;
}
.card-field > :last-child {
margin-bottom: 0;
}
.card-field p,
.card-field ul,
.card-field ol {
margin: 0 0 0.5em;
}
.card-field ul,
.card-field ol {
padding-left: 1.2em;
}
.card-field a {
color: var(--accent);
}
/* The field right under the name - a role - reads as part of the name. */
.answer-card-text .card-field:first-of-type {
color: var(--ink); color: var(--ink);
font-weight: 600; font-weight: 600;
} }
@@ -1325,6 +1347,23 @@ textarea:focus {
object-fit: cover; object-fit: cover;
} }
/* Why a send was refused, under its button. */
.alt-error {
margin: 0.6em 0 0;
color: var(--accent);
font-size: 0.92em;
}
/* The file already on a card of one's own, above the picker. */
.field-current {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A stored file on a record: its name, and it opens. */ /* A stored file on a record: its name, and it opens. */
.answer-file { .answer-file {
color: var(--accent); color: var(--accent);
+29
View File
@@ -248,3 +248,32 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
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()));
} }
#[tokio::test]
async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
use portal::answers::{keep_newest_own, own_records, OWNER_FIELD};
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
let bucket = format!("own_{}", chrono::Utc::now().timestamp_millis());
let schemas = std::collections::HashMap::new();
let now = chrono::Utc::now().timestamp_millis();
let card = |name: &str, by: &str| json!({"name": name, OWNER_FIELD: by});
// Kari's first card, then two sends of her second pressed at once,
// 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(&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.
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
let _ = (a, b);
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!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
}