Compare commits

...
5 Commits
Author SHA1 Message Date
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
11 changed files with 132 additions and 66 deletions
+8
View File
@@ -4,6 +4,14 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## Unreleased
- 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)
- 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]]
name = "portal"
version = "0.6.3"
version = "0.6.4"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.3"
version = "0.6.4"
edition = "2021"
[lib]
+16 -7
View File
@@ -82,22 +82,31 @@ values.
| `GARAGE_*` | uploads |
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
| `PORTAL_RATE_BURST`, `PORTAL_RATE_PER_SECOND` | the rate limit; see below |
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
separate unit on the same host reading the same NATS.
## Rate limiting
Every request passes a per-caller-IP limit before a session is loaded
or a handler is entered: a burst of 30, refilled one every two
seconds. A visitor filling in a form never reaches it (a page load
fetches several resources at once); a script does.
Every request that makes portal do something passes a per-caller-IP
limit before a session is loaded or a handler is entered: a burst of
100, refilled at ten a second. More than a hundred requests in ten
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_SECONDS` | seconds per refilled request, default 2 |
| `PORTAL_RATE_BURST` | how many requests at once, default 100 |
| `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
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
+2
View File
@@ -100,7 +100,9 @@ needs:
- { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true }
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
- id: soknad
paused: true
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
lands_in: soknader
+2 -1
View File
@@ -109,7 +109,8 @@
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
}
},
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
}
},
"who": {
+5 -3
View File
@@ -180,11 +180,13 @@ fn check_needs(
}
}
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!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
file.needs.len() - planned,
"OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
file.needs.len() - planned - paused,
planned,
paused,
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.
#[serde(default)]
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
/// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model.
@@ -305,11 +309,11 @@ pub fn check(
let mut report = |level: Level, message: String| {
findings.push(Finding {
need: need.id.clone(),
level: if need.planned { Level::Warn } else { level },
message: if need.planned && level == Level::Fail {
format!("planned, not met yet: {message}")
} else {
message
level: if need.planned || need.paused { Level::Warn } else { level },
message: match (need.paused, need.planned, level == Level::Fail) {
(true, _, true) => format!("paused, not open now: {message}"),
(false, true, true) => format!("planned, not met yet: {message}"),
_ => message,
},
})
};
+54 -44
View File
@@ -166,30 +166,32 @@ async fn serve() -> anyhow::Result<()> {
.with_name("portal_session");
// What a person does costs a little; what a script does costs a
// lot. A burst of 30 with one refilled every two seconds is well
// above anything a visitor filling in a form will reach - a page
// load fetches several resources at once - and well below what it
// takes to be a nuisance. Per caller IP, in memory, no store: this
// is a fence, not an accounting system, and the fence that matters
// for uploads is the policy check on the route itself.
// lot. More than a hundred requests in ten seconds is a script: a
// burst of 100, refilled at ten a second. Someone reloading a page
// again and again stays well inside it, the more so since the
// files a page is made of (stylesheet, scripts, fonts, pictures)
// are not counted at all - see `assets` below. Per caller IP, in
// memory, no store: this is a fence, not an accounting system, and
// the fence that matters for uploads is the policy check on the
// route itself.
//
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
// 0 for either turns it off, for an instance behind something that
// already does this.
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
let rate_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2);
.unwrap_or(10);
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
.unwrap_or(100);
let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
// what every one of these instances is behind; it falls back to
// the socket address when neither is set.
let conf = tower_governor::governor::GovernorConfigBuilder::default()
.per_second(rate_seconds)
.per_millisecond((1000 / rate_per_second).max(1))
.burst_size(rate_burst)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish()
@@ -197,8 +199,8 @@ async fn serve() -> anyhow::Result<()> {
std::sync::Arc::new(conf)
});
match &governor {
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
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)"),
}
async fn server_fn_handler(
@@ -233,34 +235,13 @@ async fn serve() -> anyhow::Result<()> {
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
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))
// The files a page is made of, from this disk: the stylesheet, the
// scripts, the fonts. Not counted by the rate limit - they cost
// next to nothing, and refusing one is what turns a reloaded page
// into an unstyled mess - and no session is loaded for them.
// Anything that makes portal ask someone else (content pictures
// from Gitea, map tiles) stays below, counted.
let assets: Router<AppState> = Router::new()
// no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
@@ -296,7 +277,36 @@ async fn serve() -> anyhow::Result<()> {
.service(ServeFile::new(page_motion)),
)
.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(
&state,
routes,
@@ -318,8 +328,8 @@ async fn serve() -> anyhow::Result<()> {
let app = match governor {
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
None => app,
}
.with_state(state);
};
let app = assets.merge(app).with_state(state);
tracing::info!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
+29 -4
View File
@@ -425,6 +425,14 @@ pub struct PlanNeed {
/// picture.
#[serde(default)]
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,
pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
@@ -1082,6 +1090,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"button": "Send",
"encouragement": "",
"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
// the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are
@@ -1957,15 +1967,29 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if !s(&w["who_description"]).trim().is_empty() {
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!({
"name": text(&w["heading"]),
"description": text(&w["description"]),
"description": description,
"action": action,
"consequence": button(&w["button"]),
"record_as": need.lands_in,
"features": features,
});
// 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() {
alt["encouragements"] = button(&w["encouragement"]);
}
@@ -2452,6 +2476,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<_, _>>(),
});
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();
if !others.is_empty() { need["also_moved_by"] = json!(others); }
need
@@ -2525,7 +2550,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
context.extend(
plan.needs
.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, if n.paused { " (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)" } else { "" }, 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!(
"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 +2707,7 @@ mod tests {
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
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![],
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,
collects: vec![], max_fields: 3,
}
}
+5
View File
@@ -191,6 +191,11 @@ 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
(`enters_as`), each with a `picture` field that shows as their
portrait.
- **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,
carried onto the front door as written: a strip of pictures under
the question, each with its caption.