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

Merged opened this

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

Merged

scout/forge:scout/2178-the-forge-pill-says-where-you-are-app-pr → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2178-the-forge-pill-says-where-you-are-app-pr was merged into main as c17a132.

Review

Sign in to review this change.
4 files changed +224 −12
src/apps/proxy.ts +26 −1
@@ -34,7 +34,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
3434 import { loadKey } from "../kernel/secret.ts";
3535 import { previewEnv, type EnvName } from "./data.ts";
3636 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
37 import { rewritable, withToolbar } from "./toolbar.ts";
37 import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
3838
3939 export interface AppTarget {
4040 readonly app: AppRecord;
@@ -342,6 +342,30 @@ function sealedPull(runtime: AppsRuntime, target: AppTarget): Issue | undefined
342342 return pull?.sealed === true ? pull : undefined;
343343 }
344344
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
345369 /** Whether the person a grant names may enter this app now. */
346370 function stillEntitled(
347371 runtime: AppsRuntime,
@@ -616,6 +640,7 @@ export async function proxyApp(
616640 page = withToolbar(
617641 new Uint8Array(await response.arrayBuffer()),
618642 runtime.options.forgeUrl,
643 pillWhere(runtime, target, vouched?.who),
619644 );
620645 } catch {
621646 return unavailable(runtime, target, status, "the app stopped answering");
src/apps/toolbar.ts +68 −10
@@ -31,13 +31,29 @@ export const toolbarJs = `(function () {
3131 pill.setAttribute("aria-expanded", "false");
3232 pill.textContent = "\\u2692";
3333 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");
3936 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";

Give this link padding/min-height ~44px so a thumb can hit Home on a 360px phone; it is currently one text line tall.

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);
4157 function set(open) {
4258 panel.hidden = !open;
4359 pill.setAttribute("aria-expanded", open ? "true" : "false");
@@ -45,6 +61,11 @@ export const toolbarJs = `(function () {
4561 }
4662 pill.addEventListener("click", function () { set(panel.hidden); });
4763 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 });
4869 box.appendChild(pill);
4970 box.appendChild(panel);
5071 root.appendChild(box);
@@ -97,11 +118,48 @@ export function rewritable(headers: Headers, status: number): boolean {
97118 return Number(length) <= REWRITE_MAX;
98119 }
99120
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, "&amp;")
133 .replace(/"/g, "&quot;")
134 .replace(/</g, "&lt;")
135 .replace(/>/g, "&gt;")
136 .replace(/'/g, "&#39;");
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
100156 /** `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));
105163 const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
106164 // latin1 is one character per byte, so an index in it is a byte offset.
107165 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="&lt;img src=x onerror=alert(1)&gt; &quot;q&quot; &amp; &#39;z&#39;"`,
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", () => {
127127 headers: { host: `notes.${DOMAIN}`, accept: "*/*" },
128128 redirect: "manual",
129129 });
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>`;
131132
132133 test("HTML gets one script tag before </body>, with its length fixed", async () => {
133134 toolbar = undefined;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs