Public: anyone can read this; the crew can change it.
The forge pill says where you are (app, production or preview of pull #n with its title, a link home) and closes on a tap outside it #2181
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2178-the-forge-pill-says-where-you-are-app-pr → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +224
−12
src/apps/proxy.ts +26 −1
| @@ -34,7 +34,7 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 34 | 34 | import { loadKey } from "../kernel/secret.ts"; |
| 35 | 35 | import { previewEnv, type EnvName } from "./data.ts"; |
| 36 | 36 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 37 | import { rewritable, withToolbar } from "./toolbar.ts"; | |
| 37 | import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; | |
| 38 | 38 | |
| 39 | 39 | export interface AppTarget { |
| 40 | 40 | readonly app: AppRecord; |
| @@ -342,6 +342,30 @@ function sealedPull(runtime: AppsRuntime, target: AppTarget): Issue | undefined | ||
| 342 | 342 | return pull?.sealed === true ? pull : undefined; |
| 343 | 343 | } |
| 344 | 344 | |
| 345 | /** | |
| 346 | * What the pill says: the app, the environment and, for a preview, the pull | |
| 347 | * and its title. The title goes only to a viewer who may read the pull. | |
| 348 | */ | |
| 349 | export function pillWhere( | |
| 350 | runtime: AppsRuntime, | |
| 351 | target: AppTarget, | |
| 352 | who: GrantWho | undefined, | |
| 353 | ): PillWhere { | |
| 354 | const where: PillWhere = { app: target.app.slug, env: target.env }; | |
| 355 | const number = /^pull-(\d+)$/.exec(target.env)?.[1]; | |
| 356 | if (number === undefined) return where; | |
| 357 | where.pull = Number(number); | |
| 358 | const db = runtime.app.db; | |
| 359 | const repo = getRepoById(db, target.app.repoId); | |
| 360 | const pull = getIssue(db, target.app.repoId, where.pull); | |
| 361 | if (repo === undefined || pull === undefined) return where; | |
| 362 | const viewer = who === undefined ? undefined : getUserById(db, who.id); | |
| 363 | const user = viewer !== undefined && isActive(viewer) ? viewer : undefined; | |
| 364 | if (canRead(db, repo, user) && mayView(db, repo, pull, user)) | |
| 365 | where.title = pull.title; | |
| 366 | return where; | |
| 367 | } | |
| 368 | ||
| 345 | 369 | /** Whether the person a grant names may enter this app now. */ |
| 346 | 370 | function stillEntitled( |
| 347 | 371 | runtime: AppsRuntime, |
| @@ -616,6 +640,7 @@ export async function proxyApp( | ||
| 616 | 640 | page = withToolbar( |
| 617 | 641 | new Uint8Array(await response.arrayBuffer()), |
| 618 | 642 | runtime.options.forgeUrl, |
| 643 | pillWhere(runtime, target, vouched?.who), | |
| 619 | 644 | ); |
| 620 | 645 | } catch { |
| 621 | 646 | return unavailable(runtime, target, status, "the app stopped answering"); |
src/apps/toolbar.ts +68 −10
| @@ -31,13 +31,29 @@ export const toolbarJs = `(function () { | ||
| 31 | 31 | pill.setAttribute("aria-expanded", "false"); |
| 32 | 32 | pill.textContent = "\\u2692"; |
| 33 | 33 | pill.style.cssText = "all:unset;box-sizing:border-box;cursor:pointer;width:28px;height:28px;border-radius:14px;background:#1f2328;color:#fff;text-align:center;line-height:28px;font-size:14px;opacity:.55;box-shadow:0 1px 4px rgba(0,0,0,.4)"; |
| 34 | var panel = document.createElement("a"); | |
| 35 | panel.href = home; | |
| 36 | panel.target = "_blank"; | |
| 37 | panel.rel = "noopener"; | |
| 38 | panel.textContent = "Built on forge"; | |
| 34 | var d = me.dataset || {}; | |
| 35 | var panel = document.createElement("div"); | |
| 39 | 36 | panel.hidden = true; |
| 40 | panel.style.cssText = "background:#1f2328;color:#fff;padding:6px 10px;border-radius:14px;text-decoration:none;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,.4)"; | |
| 37 | panel.style.cssText = "background:#1f2328;color:#fff;padding:8px 12px;border-radius:14px;box-shadow:0 1px 4px rgba(0,0,0,.4);max-width:min(280px,calc(100vw - 64px));display:flex;flex-direction:column;gap:2px"; | |
| 38 | function line(text, bold) { | |
| 39 | var el = document.createElement("div"); | |
| 40 | el.textContent = text; | |
| 41 | el.style.cssText = "overflow-wrap:anywhere;" + (bold ? "font-weight:600" : "opacity:.85"); | |
| 42 | panel.appendChild(el); | |
| 43 | } | |
| 44 | if (d.app) line(d.app, true); | |
| 45 | if (d.env === "production") line("Production"); | |
| 46 | else if (d.pull) line("Preview of pull #" + d.pull); | |
| 47 | if (d.title) line(d.title); | |
| 48 | var link = document.createElement("a"); | |
| 49 | link.textContent = "Home"; | |
| 50 | link.rel = "noopener"; | |
| 51 | link.style.cssText = "color:#8ab4ff;text-decoration:none;margin-top:4px"; | |
| 52 | try { | |
| 53 | var target = new URL(home); | |
| 54 | if (target.origin === new URL(me.src).origin) link.href = target.href; | |
| 55 | } catch (e) {} | |
| 56 | panel.appendChild(link); | |
| 41 | 57 | function set(open) { |
| 42 | 58 | panel.hidden = !open; |
| 43 | 59 | pill.setAttribute("aria-expanded", open ? "true" : "false"); |
| @@ -45,6 +61,11 @@ export const toolbarJs = `(function () { | ||
| 45 | 61 | } |
| 46 | 62 | pill.addEventListener("click", function () { set(panel.hidden); }); |
| 47 | 63 | document.addEventListener("keydown", function (e) { if (e.key === "Escape") set(false); }); |
| 64 | document.addEventListener("pointerdown", function (e) { | |
| 65 | if (panel.hidden) return; | |
| 66 | var path = e.composedPath ? e.composedPath() : []; | |
| 67 | if (path.indexOf(host) < 0) set(false); | |
| 68 | }); | |
| 48 | 69 | box.appendChild(pill); |
| 49 | 70 | box.appendChild(panel); |
| 50 | 71 | root.appendChild(box); |
| @@ -97,11 +118,48 @@ export function rewritable(headers: Headers, status: number): boolean { | ||
| 97 | 118 | return Number(length) <= REWRITE_MAX; |
| 98 | 119 | } |
| 99 | 120 | |
| 121 | /** What the pill says about where it is; the title only for a reader of the pull. */ | |
| 122 | export type PillWhere = { | |
| 123 | app: string; | |
| 124 | env: string; | |
| 125 | pull?: number; | |
| 126 | title?: string; | |
| 127 | }; | |
| 128 | ||
| 129 | /** `text` safe inside a double-quoted HTML attribute. */ | |
| 130 | export function escapeAttr(text: string): string { | |
| 131 | return text | |
| 132 | .replace(/&/g, "&") | |
| 133 | .replace(/"/g, """) | |
| 134 | .replace(/</g, "<") | |
| 135 | .replace(/>/g, ">") | |
| 136 | .replace(/'/g, "'"); | |
| 137 | } | |
| 138 | ||
| 139 | /** The script tag, with where-you-are as escaped data attributes. */ | |
| 140 | export function toolbarTag(forgeUrl: string, where?: PillWhere): string { | |
| 141 | const data = | |
| 142 | where === undefined | |
| 143 | ? "" | |
| 144 | : [ | |
| 145 | ["app", where.app], | |
| 146 | ["env", where.env], | |
| 147 | ["pull", where.pull === undefined ? undefined : String(where.pull)], | |
| 148 | ["title", where.title], | |
| 149 | ] | |
| 150 | .filter((pair): pair is [string, string] => pair[1] !== undefined) | |
| 151 | .map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`) | |
| 152 | .join(""); | |
| 153 | return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data} defer></script>`; | |
| 154 | } | |
| 155 | ||
| 100 | 156 | /** `bytes` with the pill's tag before the last `</body>`, or at the end. */ |
| 101 | export function withToolbar(bytes: Uint8Array, forgeUrl: string): Uint8Array { | |
| 102 | const tag = Buffer.from( | |
| 103 | `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}" defer></script>`, | |
| 104 | ); | |
| 157 | export function withToolbar( | |
| 158 | bytes: Uint8Array, | |
| 159 | forgeUrl: string, | |
| 160 | where?: PillWhere, | |
| 161 | ): Uint8Array { | |
| 162 | const tag = Buffer.from(toolbarTag(forgeUrl, where)); | |
| 105 | 163 | const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 106 | 164 | // latin1 is one character per byte, so an index in it is a byte offset. |
| 107 | 165 | const at = buffer.toString("latin1").toLowerCase().lastIndexOf("</body"); |
test/apps-pill-where.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * What the forge pill says (#2178): the app, the environment and, for a | |
| 3 | * preview, the pull and its title as escaped data attributes; the title | |
| 4 | * withheld from a viewer who may not read the pull; the panel closing on a | |
| 5 | * pointerdown outside it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { pillWhere, type AppTarget } from "../src/apps/proxy.ts"; | |
| 15 | import { toolbarJs, toolbarTag, withToolbar } from "../src/apps/toolbar.ts"; | |
| 16 | import type { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 17 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 18 | import { createIssue } from "../src/core/issues.ts"; | |
| 19 | import { createUser } from "../src/core/users.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | describe("the pill's data", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let runtime: AppsRuntime; | |
| 28 | let aliceId: string; | |
| 29 | const targets: Record<string, AppTarget> = {}; | |
| 30 | const pulls: Record<string, number> = {}; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-pill-where-")); | |
| 34 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 35 | runtime = { app } as unknown as AppsRuntime; | |
| 36 | const a = await createUser(app.db, { | |
| 37 | trusted: true, | |
| 38 | username: "alice", | |
| 39 | email: "[email protected]", | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!a.ok) throw new Error(a.error); | |
| 44 | aliceId = a.value.id; | |
| 45 | for (const [name, visibility] of [ | |
| 46 | ["open", "public"], | |
| 47 | ["closed", "private"], | |
| 48 | ] as const) { | |
| 49 | const made = await newApp(app, a.value, { name, description: "x", visibility }); | |
| 50 | if (!made.ok) throw new Error(String(made.error)); | |
| 51 | const record = getAppByRepo(app.db, made.value.repo.id)!; | |
| 52 | targets[name] = { app: record, env: "production" } as AppTarget; | |
| 53 | const pull = createIssue(app.db, { | |
| 54 | repoId: made.value.repo.id, | |
| 55 | authorId: a.value.id, | |
| 56 | title: `<img src=x onerror=alert(1)> "q" & ${name}`, | |
| 57 | kind: "pull", | |
| 58 | }); | |
| 59 | if (!pull.ok) throw new Error(pull.error); | |
| 60 | pulls[name] = pull.value.number; | |
| 61 | } | |
| 62 | }, 120_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | app.close(); | |
| 66 | await rm(dataDir, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | const preview = (name: string): AppTarget => | |
| 70 | ({ ...targets[name]!, env: `pull-${pulls[name]}` }) as AppTarget; | |
| 71 | ||
| 72 | test("production names the app and the environment, no pull", () => { | |
| 73 | expect(pillWhere(runtime, targets.open!, undefined)).toEqual({ | |
| 74 | app: targets.open!.app.slug, | |
| 75 | env: "production", | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a preview carries the pull number and, on a public repo, its title", () => { | |
| 80 | const where = pillWhere(runtime, preview("open"), undefined); | |
| 81 | expect(where.pull).toBe(pulls.open!); | |
| 82 | expect(where.env).toBe(`pull-${pulls.open}`); | |
| 83 | expect(where.title).toContain("onerror"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("the title is withheld from a viewer who may not read the pull", () => { | |
| 87 | const anon = pillWhere(runtime, preview("closed"), undefined); | |
| 88 | expect(anon.pull).toBe(pulls.closed!); | |
| 89 | expect(anon.title).toBeUndefined(); | |
| 90 | const owner = pillWhere(runtime, preview("closed"), { id: aliceId } as never); | |
| 91 | expect(owner.title).toContain("closed"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a title with markup is escaped inside the attribute", () => { | |
| 95 | const tag = toolbarTag("http://f.test", { | |
| 96 | app: "a", | |
| 97 | env: "pull-3", | |
| 98 | pull: 3, | |
| 99 | title: `<img src=x onerror=alert(1)> "q" & 'z'`, | |
| 100 | }); | |
| 101 | expect(tag).not.toContain("<img"); | |
| 102 | expect(tag).toContain( | |
| 103 | `data-title="<img src=x onerror=alert(1)> "q" & 'z'"`, | |
| 104 | ); | |
| 105 | expect(tag.match(/<script/g)?.length).toBe(1); | |
| 106 | const page = new TextDecoder().decode( | |
| 107 | withToolbar(new TextEncoder().encode("<body></body>"), "http://f.test", { | |
| 108 | app: "a", | |
| 109 | env: "production", | |
| 110 | }), | |
| 111 | ); | |
| 112 | expect(page).toContain(`data-app="a" data-env="production"`); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | describe("the pill's script", () => { | |
| 117 | test("registers the outside-pointerdown close beside Escape and the tap", () => { | |
| 118 | expect(toolbarJs).toContain('addEventListener("pointerdown"'); | |
| 119 | expect(toolbarJs).toContain('"Escape"'); | |
These assert on source strings, not behaviour. Run the script against a DOM stub (or look in a browser) so breaking the outside-tap close fails the test. |
||
| 120 | expect(toolbarJs).toContain('pill.addEventListener("click"'); | |
| 121 | }); | |
| 122 | ||
| 123 | test("puts text in with textContent and checks the home href against the forge origin", () => { | |
| 124 | expect(toolbarJs).toContain("textContent = text"); | |
| 125 | expect(toolbarJs).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/); | |
| 126 | expect(toolbarJs).toContain("target.origin === new URL(me.src).origin"); | |
| 127 | }); | |
| 128 | }); |
test/apps-toolbar.test.ts +2 −1
| @@ -127,7 +127,8 @@ describe("the forge pill", () => { | ||
| 127 | 127 | headers: { host: `notes.${DOMAIN}`, accept: "*/*" }, |
| 128 | 128 | redirect: "manual", |
| 129 | 129 | }); |
| 130 | const tag = () => `<script src="${servers.url}${TOOLBAR_PATH}" defer></script>`; | |
| 130 | const tag = () => | |
| 131 | `<script src="${servers.url}${TOOLBAR_PATH}" data-app="notes" data-env="production" defer></script>`; | |
| 131 | 132 | |
| 132 | 133 | test("HTML gets one script tag before </body>, with its length fixed", async () => { |
| 133 | 134 | toolbar = undefined; |
new above
offline
Give this link padding/min-height ~44px so a thumb can hit Home on a 360px phone; it is currently one text line tall.