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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3563-a-browser-can-search-the-forge-from-its → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/3563-a-browser-can-search-the-forge-from-its was merged into main as 371529c.

Review

Sign in to review this change.
7 files changed +104 −1
src/core/reserved.ts +1
@@ -78,6 +78,7 @@ export const SYSTEM_NAMES: readonly string[] = [
7878 "login",
7979 "logout",
8080 "manifest.webmanifest",
81 "opensearch.xml",
8182 "map",
8283 "mcp",
8384 "contract",
src/http/server.ts +2
@@ -135,6 +135,7 @@ import {
135135 signinOptions,
136136 signinPasskey,
137137 } from "../web/pages/passkeys.ts";
138 import { openSearch } from "../web/opensearch.ts";
138139 import { confirmPage, searchPage } from "../web/pages/search.ts";
139140 import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts";
140141 import { followingSubmit } from "../web/pages/following.ts";
@@ -656,6 +657,7 @@ export function buildRouter(): Router<Handler> {
656657 router.add("GET", "/releases.json", releasesJson);
657658 router.add("GET", "/sitemap.xml", sitemapXml);
658659 router.add("GET", "/manifest.webmanifest", webManifest);
660 router.add("GET", "/opensearch.xml", openSearch);
659661 router.add("GET", "/icons/:spec.png", iconPng);
660662 // The highlighter bundle, content-addressed. A stale hash is a 404 rather
661663 // than a redirect: the page that asked for it names the hash it was built
src/web/layout.ts +7
@@ -7,6 +7,7 @@
77 * `/search`), and every dialog (a link to a real page).
88 */
99
10 import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts";
1011 import { protectionNotice } from "../kernel/protection.ts";
1112 import { appsForRepos } from "../core/apps.ts";
1213 import { listReposVisibleTo } from "../core/repos.ts";
@@ -585,6 +586,12 @@ export function page(options: PageOptions, status = 200): Response {
585586 <meta property="og:image:height" content="630" />
586587 <meta name="twitter:card" content="summary_large_image" />
587588 <link rel="manifest" href="/manifest.webmanifest" />
589 <link
590 rel="search"
591 type="${OPENSEARCH_TYPE}"
592 href="${OPENSEARCH_PATH}"
593 title="${OPENSEARCH_TITLE}"
594 />
588595 ${links.map(
589596 (l) =>
590597 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 {

Non-blocking: this repeats text() from src/web/standards.ts (same public, max-age=3600). A one-line mention in that module's header list of well-known files would keep the family together.

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");
1111
1212 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1313 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$)/;
1515
1616 const getRoutes = [
1717 ...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> = {
2525 "/releases.json": "JSON feed of /releases",
2626 "/sitemap.xml": "XML for crawlers",
2727 "/manifest.webmanifest": "the install manifest, JSON",
28 "/opensearch.xml": "the browser search description, XML",
2829 "/sw.js": "the service worker script",
2930 "/audit.csv": "a CSV download of /audit",
3031 "/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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs