Public: anyone can read this; the crew can change it.
A browser can search the forge from its address bar: /opensearch.xml and a link to it on every page #3620
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3563-a-browser-can-search-the-forge-from-its → main 3 commits fork 1 thread · 1 unresolved
7 files
changed +104
−1
src/core/reserved.ts +1
| @@ -78,6 +78,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 78 | 78 | "login", |
| 79 | 79 | "logout", |
| 80 | 80 | "manifest.webmanifest", |
| 81 | "opensearch.xml", | |
| 81 | 82 | "map", |
| 82 | 83 | "mcp", |
| 83 | 84 | "contract", |
src/http/server.ts +2
| @@ -135,6 +135,7 @@ import { | ||
| 135 | 135 | signinOptions, |
| 136 | 136 | signinPasskey, |
| 137 | 137 | } from "../web/pages/passkeys.ts"; |
| 138 | import { openSearch } from "../web/opensearch.ts"; | |
| 138 | 139 | import { confirmPage, searchPage } from "../web/pages/search.ts"; |
| 139 | 140 | import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts"; |
| 140 | 141 | import { followingSubmit } from "../web/pages/following.ts"; |
| @@ -656,6 +657,7 @@ export function buildRouter(): Router<Handler> { | ||
| 656 | 657 | router.add("GET", "/releases.json", releasesJson); |
| 657 | 658 | router.add("GET", "/sitemap.xml", sitemapXml); |
| 658 | 659 | router.add("GET", "/manifest.webmanifest", webManifest); |
| 660 | router.add("GET", "/opensearch.xml", openSearch); | |
| 659 | 661 | router.add("GET", "/icons/:spec.png", iconPng); |
| 660 | 662 | // The highlighter bundle, content-addressed. A stale hash is a 404 rather |
| 661 | 663 | // than a redirect: the page that asked for it names the hash it was built |
src/web/layout.ts +7
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * `/search`), and every dialog (a link to a real page). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts"; | |
| 10 | 11 | import { protectionNotice } from "../kernel/protection.ts"; |
| 11 | 12 | import { appsForRepos } from "../core/apps.ts"; |
| 12 | 13 | import { listReposVisibleTo } from "../core/repos.ts"; |
| @@ -585,6 +586,12 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 585 | 586 | <meta property="og:image:height" content="630" /> |
| 586 | 587 | <meta name="twitter:card" content="summary_large_image" /> |
| 587 | 588 | <link rel="manifest" href="/manifest.webmanifest" /> |
| 589 | <link | |
| 590 | rel="search" | |
| 591 | type="${OPENSEARCH_TYPE}" | |
| 592 | href="${OPENSEARCH_PATH}" | |
| 593 | title="${OPENSEARCH_TITLE}" | |
| 594 | /> | |
| 588 | 595 | ${links.map( |
| 589 | 596 | (l) => |
| 590 | 597 | html`<link |
src/web/opensearch.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * The forge as a browser search engine (OpenSearch 1.1): type the forge's | |
| 3 | * name in the address bar, tab, and the query goes to /search. | |
| 4 | * | |
| 5 | * The URL is built from the configured origin, never from the request's | |
| 6 | * Host header: a description is installed once and trusted afterwards, so | |
| 7 | * a forged Host must not be able to point it somewhere else. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import type { Ctx } from "../http/context.ts"; | |
| 12 | import { escapeHtml } from "./html.ts"; | |
| 13 | ||
| 14 | export const OPENSEARCH_PATH = "/opensearch.xml"; | |
| 15 | export const OPENSEARCH_TYPE = "application/opensearchdescription+xml"; | |
| 16 | export const OPENSEARCH_TITLE = "forge"; | |
| 17 | ||
| 18 | function origin(app: App): string { | |
| 19 | return new URL(app.config.url ?? `http://localhost:${app.config.port}`).origin; | |
| 20 | } | |
| 21 | ||
| 22 | /** The description document for this forge. */ | |
| 23 | export function openSearchXml(app: App): string { | |
| 24 | const base = origin(app); | |
| 25 | const name = escapeHtml(OPENSEARCH_TITLE); | |
| 26 | return `<?xml version="1.0" encoding="UTF-8"?> | |
| 27 | <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> | |
| 28 | <ShortName>${name}</ShortName> | |
| 29 | <Description>Search ${name}</Description> | |
| 30 | <InputEncoding>UTF-8</InputEncoding> | |
| 31 | <Url type="text/html" method="get" template="${escapeHtml(base)}/search?q={searchTerms}"/> | |
| 32 | <Url type="${OPENSEARCH_TYPE}" rel="self" template="${escapeHtml(base)}${OPENSEARCH_PATH}"/> | |
| 33 | </OpenSearchDescription> | |
| 34 | `; | |
| 35 | } | |
| 36 | ||
| 37 | /** GET /opensearch.xml */ | |
| 38 | export function openSearch(ctx: Ctx): Response { | |
| 39 | return new Response(openSearchXml(ctx.app), { | |
| 40 | headers: { | |
| 41 | "content-type": `${OPENSEARCH_TYPE}; charset=utf-8`, | |
| 42 | "cache-control": "public, max-age=3600", | |
| 43 | }, | |
| 44 | }); | |
| 45 | } |
test/design-audit.test.ts +1 −1
| @@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 11 | 11 | |
| 12 | 12 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 13 | 13 | const NOT_PAGES = |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 15 | 15 | |
| 16 | 16 | const getRoutes = [ |
| 17 | 17 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/forge-looks.test.ts +1
| @@ -25,6 +25,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 25 | 25 | "/releases.json": "JSON feed of /releases", |
| 26 | 26 | "/sitemap.xml": "XML for crawlers", |
| 27 | 27 | "/manifest.webmanifest": "the install manifest, JSON", |
| 28 | "/opensearch.xml": "the browser search description, XML", | |
| 28 | 29 | "/sw.js": "the service worker script", |
| 29 | 30 | "/audit.csv": "a CSV download of /audit", |
| 30 | 31 | "/claim": "answers 404 to an admin: it exists only for a link-holder", |
test/opensearch.test.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | ||
| 8 | describe("the forge as a browser search engine", () => { | |
| 9 | let dataDir: string; | |
| 10 | let servers: Servers; | |
| 11 | let base: string; | |
| 12 | ||
| 13 | beforeAll(async () => { | |
| 14 | dataDir = await mkdtemp(join(tmpdir(), "forge-opensearch-")); | |
| 15 | const app = createApp({ dataDir, port: 0, url: "https://forge.example.org" }); | |
| 16 | servers = await serve(app); | |
| 17 | base = servers.url; | |
| 18 | }); | |
| 19 | afterAll(async () => { | |
| 20 | await servers.stop(); | |
| 21 | await rm(dataDir, { recursive: true, force: true }); | |
| 22 | }); | |
| 23 | ||
| 24 | test("the description's template is the search page and the search route answers it", async () => { | |
| 25 | const res = await fetch(`${base}/opensearch.xml`); | |
| 26 | expect(res.status).toBe(200); | |
| 27 | expect(res.headers.get("content-type")).toContain("application/opensearchdescription+xml"); | |
| 28 | expect(res.headers.get("cache-control")).toContain("max-age"); | |
| 29 | const xml = await res.text(); | |
| 30 | expect(xml).toContain("<ShortName>forge</ShortName>"); | |
| 31 | const template = /<Url type="text\/html"[^>]*template="([^"]+)"/.exec(xml)?.[1]; | |
| 32 | expect(template).toBe("https://forge.example.org/search?q={searchTerms}"); | |
| 33 | const path = new URL(template!.replace("{searchTerms}", "hello")).pathname; | |
| 34 | const found = await fetch(`${base}${path}?q=hello`); | |
| 35 | expect(found.status).toBe(200); | |
| 36 | }); | |
| 37 | ||
| 38 | test("a forged Host never reaches the description", async () => { | |
| 39 | const res = await fetch(`${base}/opensearch.xml`, { headers: { host: "evil.example" } }); | |
| 40 | expect(await res.text()).not.toContain("evil.example"); | |
| 41 | }); | |
| 42 | ||
| 43 | test("every page's head links to it", async () => { | |
| 44 | const page = await (await fetch(`${base}/`)).text(); | |
| 45 | expect(page).toMatch(/<link\s+rel="search"\s+type="application\/opensearchdescription\+xml"\s+href="\/opensearch\.xml"\s+title="forge"/); | |
| 46 | }); | |
| 47 | }); |
new above
offline
Non-blocking: this repeats
text()fromsrc/web/standards.ts(samepublic, max-age=3600). A one-line mention in that module's header list of well-known files would keep the family together.