Public: anyone can read this; the crew can change it.
The 404 page offers a search for what was asked, and a stranger Create an account beside Sign in #8582
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8483-the-404-page-offers-a-search-for-what-wa → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +45
−4
src/web/chrome.ts +19 −3
| @@ -72,8 +72,12 @@ export function crumbsFor(repo: Repo): { label: string; href: string }[] { | ||
| 72 | 72 | * this address; `plain` leaves that out for a miss that must reveal nothing |
| 73 | 73 | * (a dead `/go/:code`). */ |
| 74 | 74 | export function notFoundPage(ctx: Ctx, opts: { plain?: boolean } = {}): Response { |
| 75 | const offerSignIn = | |
| 76 | !opts.plain && ctx.auth.user === undefined && ctx.request.method === "GET"; | |
| 75 | const get = !opts.plain && ctx.request.method === "GET"; | |
| 76 | const offerSignIn = get && ctx.auth.user === undefined; | |
| 77 | const asked = get ? askedFor(ctx.url.pathname) : ""; | |
| 78 | const search = asked | |
| 79 | ? btn(`Search for ‘${asked}’`, { href: `/search?q=${encodeURIComponent(asked)}` }) | |
| 80 | : html``; | |
| 77 | 81 | return page( |
| 78 | 82 | { |
| 79 | 83 | ctx, |
| @@ -87,8 +91,9 @@ export function notFoundPage(ctx: Ctx, opts: { plain?: boolean } = {}): Response | ||
| 87 | 91 | ? btn("Go home", { href: "/", variant: "primary" }) |
| 88 | 92 | : offerSignIn |
| 89 | 93 | ? html`${btn("Sign in", { href: signInHref(ctx), variant: "primary" })} |
| 94 | ${btn("Create an account", { href: "/signup" })} ${search} | |
| 90 | 95 | ${btn("Go home", { href: "/" })} ${btn("Explore", { href: "/explore" })}` |
| 91 | : html`${btn("Go home", { href: "/", variant: "primary" })} | |
| 96 | : html`${btn("Go home", { href: "/", variant: "primary" })} ${search} | |
| 92 | 97 | ${btn("Explore", { href: "/explore" })}`, |
| 93 | 98 | })}`, |
| 94 | 99 | }, |
| @@ -96,6 +101,17 @@ export function notFoundPage(ctx: Ctx, opts: { plain?: boolean } = {}): Response | ||
| 96 | 101 | ); |
| 97 | 102 | } |
| 98 | 103 | |
| 104 | /** What the reader asked for: the last path segment, URL-decoded, trimmed, | |
| 105 | * at most 100 characters; empty for `/` or an undecodable segment. */ | |
| 106 | function askedFor(pathname: string): string { | |
| 107 | const segment = pathname.slice(pathname.lastIndexOf("/") + 1); | |
| 108 | try { | |
Next: a trailing slash gives an empty last segment, so no search is offered; skip empty segments. |
||
| 109 | return decodeURIComponent(segment).trim().slice(0, 100).trim(); | |
| 110 | } catch { | |
| 111 | return ""; | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 99 | 115 | export const repoNotFound = notFoundPage; |
| 100 | 116 | |
| 101 | 117 | /** Why a refusal happened, in the words a person reads on the 403 page. */ |
test/web-origins.test.ts +3
| @@ -771,6 +771,9 @@ describe("origins on the web", () => { | ||
| 771 | 771 | redirect: "manual", |
| 772 | 772 | }); |
| 773 | 773 | expect(unknown.status).toBe(404); |
| 774 | const plainBody = await unknown.clone().text(); | |
| 775 | expect(plainBody).not.toContain("/search?q="); | |
| 776 | expect(plainBody).not.toContain("Create an account"); | |
| 774 | 777 | app.db |
| 775 | 778 | .query("UPDATE oauth_flow SET expires_at = ?1 WHERE id = ?2") |
| 776 | 779 | .run("2020-01-01T00:00:00.000Z", flowId); |
test/web-ui.test.ts +23 −1
| @@ -456,7 +456,7 @@ describe("web ui", () => { | ||
| 456 | 456 | const hidden = await (await fetch(`${base}/kim/secret`)).text(); |
| 457 | 457 | const absent = await (await fetch(`${base}/kim/nothing`)).text(); |
| 458 | 458 | const scrub = (t: string) => |
| 459 | t.replaceAll(/nonce-?="?[\w-]+/g, "nonce").replaceAll(/here\?path=[^"&\s]*/g, "here?path=").replaceAll(/next=[^"]*/g, ""); | |
| 459 | t.replaceAll(/nonce-?="?[\w-]+/g, "nonce").replaceAll(/here\?path=[^"&\s]*/g, "here?path=").replaceAll(/next=[^"]*/g, "").replaceAll(/q=[^"]*"/g, 'q="').replaceAll(/Search for ‘[^’]*’/g, "Search for"); | |
| 460 | 460 | expect(scrub(hidden)).toBe(scrub(absent)); |
| 461 | 461 | |
| 462 | 462 | const signedIn = await ( |
| @@ -468,6 +468,28 @@ describe("web ui", () => { | ||
| 468 | 468 | expect(signedIn).toContain('href="/explore"'); |
| 469 | 469 | }); |
| 470 | 470 | |
| 471 | test("a 404 offers a search for what was asked, and a stranger an account", async () => { | |
| 472 | const out = await (await fetch(`${base}/kim/nothing`)).text(); | |
| 473 | expect(out).toContain('href="/search?q=nothing"'); | |
| 474 | expect(out).toContain("Search for ‘nothing’"); | |
| 475 | expect(out).toContain('href="/signup"'); | |
| 476 | expect(out).toContain("Create an account"); | |
| 477 | ||
| 478 | const odd = await (await fetch(`${base}/%3Cx%3E`)).text(); | |
| 479 | expect(odd).toContain("Search for ‘<x>’"); | |
| 480 | expect(odd).toContain('href="/search?q=%3Cx%3E"'); | |
| 481 | expect(odd).not.toContain("<x>"); | |
| 482 | ||
| 483 | const long = await (await fetch(`${base}/${"a".repeat(150)}`)).text(); | |
| 484 | expect(long).toContain(`q=${"a".repeat(100)}"`); | |
| 485 | ||
| 486 | const signedIn = await ( | |
| 487 | await fetch(`${base}/kim/nothing`, { headers: { cookie: stranger } }) | |
| 488 | ).text(); | |
| 489 | expect(signedIn).toContain("Search for ‘nothing’"); | |
| 490 | expect(signedIn).not.toContain("Create an account"); | |
| 491 | }); | |
| 492 | ||
| 471 | 493 | test("safeNext refuses another origin", () => { |
| 472 | 494 | expect(safeNext("//evil.example/x")).toBeUndefined(); |
| 473 | 495 | expect(safeNext("https://evil.example")).toBeUndefined(); |
new above
offline
Next: when signInHref returns /signup (edge stranger), Sign in and Create an account are the same destination; show only one.