Modules

Routes, forms and members

Routes

A path may name parameters, /shop/:slug, which the handler gets as req.params.slug; a trailing /* matches the rest as req.params["*"]. When several routes match, the most literal wins (/shop/new before /shop/:slug before /shop/*). The pattern must still fall within the manifest's routes (/shop/* covers all three). Pages and content types the site has win over module routes for the paths they own.

The handler gets { method, path, params, query, form, files, json, body, headers, cookies, ip, locale, user }: body is the raw text (for webhook signatures), headers every request header except cookies, cookies this module's own (below), ip the visitor's address (a proxy on the same machine or network may name it in X-Forwarded-For), locale the language prefix the request came under, and user { id, email, name, roles, capabilities } for a signed-in visitor, else null.

The third argument asks core to check things before the handler runs:

ctx.routes.get("/account/orders", orders, { auth: true })?;          // signed in, else sent to /sign-in?next=… (401 for non-pages)
ctx.routes.get("/club", club, { role: "member" })?;                  // that role, else 403
ctx.routes.get("/forms/:slug/export", export, { capability: "edit_pages" })?;
ctx.routes.post("/forms/:slug", submit, { rate: "10/m" })?;          // per visitor address; then 429 with Retry-After
ctx.routes.post("/hooks/payments", webhook, { cross_site: true })?;  // see below

A browser posting to a module route from another site (judged by Sec-Fetch-Site, or Origin against Host) is refused before the handler runs, unless the route says cross_site: true. Requests from other servers (webhooks, scripts) carry neither header and are let through. For a cross_site route core also answers browsers' OPTIONS preflight.

Besides HTML, a handler may answer:

AnswerSends
ctx.json(v)JSON
ctx.text(csv, "text/csv")that body and type, outside the site's chrome
ctx.redirect(url)303 (or ctx.redirect(url, 301); 302, 307 and 308 too)
ctx.status(422, { errors })JSON with that status; ctx.status(204) sends nothing
ctx.response({ status, html | json | text, content_type, headers, cookies, title, raw })everything at once

headers may set cache-control, content-disposition, content-language, link, vary, retry-after, www-authenticate, etag, last-modified, access-control-* and anything starting x-. cookies is a map: { cart: "abc" }, { seen: { value: "1", max_age: 86400, http_only: true, same_site: "lax" } }, or { old: null } to remove one. They are stored as flm_<module>_<name>, HttpOnly and SameSite=Lax unless the module says otherwise and Secure on an https site, and come back as req.cookies.<name>; a module never sees another's.

Public forms and webhooks

A form on a public page carries a token from ctx.forms.token("contact") in a hidden field; the route checks it with ctx.forms.check(req.form._token, "contact", { min_seconds: 3, max_age: "1d" }). The token is signed with a secret the site keeps (so it cannot be forged or used for another module or form), and dated, so a form sent within min_seconds (what robots do) or after max_age is refused with a reason to show. Together with the cross-site check, a hidden field people never fill in, and ctx.limits.hit("submit:" + req.ip, "20/h"), that keeps robots out without puzzles. The forms module (gansey-module-forms) does all of it.

A webhook going out is sent to an address the site owner types, so the manifest cannot name its host. http = ["*"] lets a module call any public address, and core checks each request it allows only that way (hosts the manifest names are called as before): http or https only; never localhost or a name under .localhost, .local or .internal; the host is looked up and refused when any of its addresses is loopback, private, shared (100.64.0.0/10), link-local, unique-local, unspecified, multicast or broadcast, IPv4-mapped IPv6 forms included. The connection goes straight to the address that was checked, never through a proxy, and redirects are not followed (a 3xx is the answer). The Err says why, as in `host hooks.example.test resolves to a private address (10.0.0.5, private); for ctx.http.background a refusal that needed the lookup comes to the hook as error. Installing such a module shows "http: any public address". The forms module (gansey-module-forms, from 2.0) posts submissions to webhooks this way and sends its notifications with cc, bcc and reply_to`.

The robot check

For forms that need more (signing in, signing up, checkout), the site has a robot check of its own: the browser does a small piece of work before the form goes, with a tick box ("I'm not a robot") or a short plain-text question in front of it, as Settings → Robot check chooses; without JavaScript the question is asked instead. Nothing is loaded from another host, and the widget takes its colours from the theme (--gsy-captcha-* custom properties, set under Theme → Site parts → Styles → Robot check and falling back to the theme tokens).

fn form(req) -> Result<Html, Error> {
  ctx.render("checkout.html", { captcha: ctx.captcha.html({}, req.ip) })   // {{{ captcha }}} inside the <form>
}

fn pay(req) -> Result<Map, Error> {
  match ctx.captcha.check(req) {
    Ok(_) => {},
    Err(why) => { return refuse(req, str(why)); },       // what to show the person
  }
  …
}

html is empty, and check answers Ok, when the site has the check off or has not asked for it on modules' forms, so both can always be called. A widget drawn into a page the site caches should say { fresh: false }: it then carries no challenge of its own and the browser asks for one (people without JavaScript are told to turn it on, so give such a form a page of its own as well, as the forms module does). ctx.captcha.active() says whether modules' forms get the check at all. The accounts module (gansey-module-accounts) puts it in front of signing in and signing up; the forms module adds it to every form.

A webhook route takes cross_site: true and checks the sender's signature over the raw body:

fn webhook(req) -> Result<Map, Error> {
  let want = ctx.crypto.hmac_sha256(ctx.settings.get("secret"), req.body);
  if !ctx.crypto.equal(want, req.headers["x-signature"]) { return ctx.status(401, { error: "bad signature" }); }
  ctx.queue.push("payment", req.json)?;      // answer quickly; do the work in the queue
  ctx.status(204)
}

Members

A site can let people sign in without giving them the admin: customers, club members. Give them the member role (it has no capabilities, so the admin API refuses them). They use pages core serves on the public site, in the site's own header and footer:

PageDoes
/sign-in?next=/pathemail and password, or an emailed sign-in link
/sign-in/codethe authenticator code, when the account has one
/sign-in/passwordchoose or change a password (invitations land here)
/sign-in/securityturn an authenticator app on or off
/sign-outsign out

After signing in, members go to [auth].members_home (default /) unless next names a page on the site; staff go to the admin. A module route that needs a member says so, ctx.routes.get("/club", club, { role: "member" }), and core sends everyone else to sign in.

Language prefixes

A module with locales = true (a multi-language module) calls ctx.locales.configure({ default: "en", prefixes: ["fr", "de"] }). Core then answers /fr/about with the /about page, <html lang="fr">, its own cache entry, and locale: "fr" in head, page.html, response.headers, search.query and route requests; module routes match without the prefix. What the page says in French is the module's to change (through page.html, or entries it keeps per language). The prefixes apply while the module runs; configure(null) removes them.