bottleship 0.1.0-prototype.1 → 0.1.0-prototype.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +118 -117
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -84
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +2 -4
- package/dist/src/init.js +92 -228
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- package/presets/providers/util.ts +0 -112
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* playground only */
|
|
2
|
+
.still *, .still *::before, .still *::after { animation: none !important; transition: none !important; }
|
|
3
|
+
.gallery-main { overflow: auto; height: 100vh; }
|
|
4
|
+
.g-sec { padding: 48px 56px; border-bottom: 1px solid var(--line); max-width: 1180px; }
|
|
5
|
+
.g-head { margin-bottom: 28px; }
|
|
6
|
+
.g-head h2 { font-size: 19px; font-weight: 500; letter-spacing: -0.015em; margin: 0; }
|
|
7
|
+
.g-head p { color: var(--text-2); margin: 6px 0 0; }
|
|
8
|
+
.g-body { display: flex; flex-direction: column; gap: 32px; }
|
|
9
|
+
.g-spec { display: grid; grid-template-columns: 180px 1fr; gap: 24px; align-items: start; }
|
|
10
|
+
.g-name { font: 12px var(--mono); color: var(--text-3); padding-top: 4px; }
|
|
11
|
+
.g-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
12
|
+
.g-swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
|
13
|
+
.g-swatches div { display: flex; align-items: center; gap: 10px; font-size: 12px; }
|
|
14
|
+
.g-swatches i { width: 28px; height: 28px; border-radius: 7px; border: 1px solid var(--line-2); flex: none; }
|
|
15
|
+
.g-frames { height: 520px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
|
16
|
+
.g-frames .live { height: 100%; padding: 12px; }
|
|
17
|
+
.rail .g-link { color: var(--text-2); text-decoration: none; padding: 6px 10px; border-radius: 7px; font-size: 13.5px; }
|
|
18
|
+
.rail .g-link:hover { background: var(--hover); color: var(--text); }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The playground: the review kit and frame, run locally against a recorded review (no box needed).
|
|
2
|
+
// npm run playground the excalidraw#12602 review, a visual change
|
|
3
|
+
// FIXTURE=rply-16 npm run playground the rply#16 review, a backend change with flows
|
|
4
|
+
// LOCAL=1 npm run playground either one as a review of local changes, for the developer's agent
|
|
5
|
+
// Open /#/gallery for every component in every state.
|
|
6
|
+
import { defineConfig, type Plugin } from "vite";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import react from "@vitejs/plugin-react";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { mockReviewServer } from "./mock";
|
|
11
|
+
|
|
12
|
+
const here = (p: string) => fileURLToPath(new URL(p, import.meta.url));
|
|
13
|
+
const fixture = here(`./fixtures/${process.env.FIXTURE ?? "excalidraw-12602"}`);
|
|
14
|
+
const local = !!process.env.LOCAL;
|
|
15
|
+
|
|
16
|
+
// a PR fixture read as local changes: no number, no link, the branch as its title
|
|
17
|
+
const asLocal: Plugin = {
|
|
18
|
+
name: "fixture-as-local",
|
|
19
|
+
enforce: "pre",
|
|
20
|
+
load(id) {
|
|
21
|
+
if (!local || !id.endsWith("/data/pr.json")) return;
|
|
22
|
+
const pr = JSON.parse(readFileSync(id, "utf8"));
|
|
23
|
+
return JSON.stringify({ ...pr, number: 0, url: "", repo: pr.url.split("/")[4], title: pr.headRefName, comments: [], reviews: [] });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default defineConfig({
|
|
28
|
+
root: here("."),
|
|
29
|
+
plugins: [asLocal, react(), mockReviewServer(fixture, local)],
|
|
30
|
+
resolve: { alias: { "@data": `${fixture}/data`, "@review": `${fixture}/review.tsx` } },
|
|
31
|
+
server: { port: 5199, fs: { allow: [here("..")] } },
|
|
32
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Opens every page of the built review in Chromium, the way the reviewer will see it, and fails on any problem:
|
|
3
|
+
// script errors, failed requests to the review server, and images that do not load.
|
|
4
|
+
// Saves a screenshot of each page to ~/review/checks so you can look at what you built.
|
|
5
|
+
const { mkdirSync } = require("node:fs");
|
|
6
|
+
const { join, resolve } = require("node:path");
|
|
7
|
+
const { chromium } = require("playwright");
|
|
8
|
+
|
|
9
|
+
const base = (process.env.BOTTLESHIP_REVIEW_URL ?? "http://127.0.0.1:7777").replace(/\/$/, "");
|
|
10
|
+
const out = resolve(__dirname, "../../checks");
|
|
11
|
+
mkdirSync(out, { recursive: true });
|
|
12
|
+
|
|
13
|
+
(async () => {
|
|
14
|
+
const browser = await chromium.launch();
|
|
15
|
+
const problems = [];
|
|
16
|
+
try {
|
|
17
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
18
|
+
let where = "/";
|
|
19
|
+
page.on("pageerror", (e) => problems.push(`${where}: ${e.message}`));
|
|
20
|
+
page.on("console", (m) => m.type() === "error" && (m.location().url ?? "").startsWith(base) && problems.push(`${where}: console: ${m.text()}`));
|
|
21
|
+
page.on("response", (r) => r.url().startsWith(base) && r.status() >= 400 && problems.push(`${where}: ${r.status()} ${r.url().slice(base.length)}`));
|
|
22
|
+
await page.goto(`${base}/`, { waitUntil: "domcontentloaded" });
|
|
23
|
+
await page.waitForFunction(() => Array.isArray(window.__REVIEW_ROUTES__), null, { timeout: 10000 }).catch(() => problems.push("/: the review did not render (no routes); is the app built and the review server running?"));
|
|
24
|
+
const routes = (await page.evaluate(() => window.__REVIEW_ROUTES__)) ?? [];
|
|
25
|
+
for (const [i, route] of routes.entries()) {
|
|
26
|
+
where = route;
|
|
27
|
+
await page.goto(`${base}/${route}`, { waitUntil: "domcontentloaded" });
|
|
28
|
+
await page.waitForTimeout(900);
|
|
29
|
+
const broken = await page.evaluate(() => [...document.images].filter((img) => img.complete && img.naturalWidth === 0).map((img) => img.getAttribute("src")));
|
|
30
|
+
broken.forEach((src) => problems.push(`${route}: image does not load: ${src}`));
|
|
31
|
+
const clips = await page.evaluate(() => [...document.querySelectorAll("video")].filter((v) => v.error || v.networkState === 3).map((v) => v.getAttribute("src")));
|
|
32
|
+
clips.forEach((src) => problems.push(`${route}: video does not load: ${src}`));
|
|
33
|
+
const name = route.replace(/^#\/?/, "").replace(/[^\w-]+/g, "-") || "overview";
|
|
34
|
+
await page.screenshot({ path: join(out, `${String(i + 1).padStart(2, "0")}-${name}.png`), fullPage: true });
|
|
35
|
+
}
|
|
36
|
+
console.log(`checked ${routes.length} pages; screenshots in ${out}`);
|
|
37
|
+
} finally {
|
|
38
|
+
await browser.close();
|
|
39
|
+
}
|
|
40
|
+
if (problems.length) {
|
|
41
|
+
console.error([...new Set(problems)].map((p) => `- ${p}`).join("\n"));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
})().catch((e) => {
|
|
45
|
+
console.error(e.message);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Records a short video of a running instance into ~/review/shots, for <Clip>: the page, then each action at a
|
|
3
|
+
// person's pace, with a cursor drawn in so the viewer sees where it clicks.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/clip.cjs --url http://localhost:7781/board --out base-drag
|
|
6
|
+
// [--do "click:button.add" --do "type:#title=Ship it" --do "press:Enter" --do "hover:.card"
|
|
7
|
+
// --do "drag:.card=.done" --do "scroll:600" --do "wait:800"]
|
|
8
|
+
// [--viewport 1280x800] [--scheme light|dark] [--locale he-IL] [--pace 600]
|
|
9
|
+
//
|
|
10
|
+
// Prints the src to put in <Clip> (shots/<out>.webm#t=<seconds>): the part before the page was ready is skipped.
|
|
11
|
+
const { parseArgs } = require("node:util");
|
|
12
|
+
const { mkdirSync, mkdtempSync, rmSync } = require("node:fs");
|
|
13
|
+
const { tmpdir } = require("node:os");
|
|
14
|
+
const { join, resolve } = require("node:path");
|
|
15
|
+
const { chromium } = require("playwright");
|
|
16
|
+
|
|
17
|
+
const { values: o } = parseArgs({
|
|
18
|
+
options: {
|
|
19
|
+
url: { type: "string" }, out: { type: "string" }, do: { type: "string", multiple: true, default: [] },
|
|
20
|
+
viewport: { type: "string", default: "1280x800" }, scheme: { type: "string", default: "light" }, locale: { type: "string" }, pace: { type: "string", default: "600" },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!o.url || !o.out) {
|
|
24
|
+
console.error("usage: clip.cjs --url <url> --out <name> [--do action]... (see the top of this file)");
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const shots = resolve(__dirname, "../../shots");
|
|
29
|
+
mkdirSync(shots, { recursive: true });
|
|
30
|
+
const [width, height] = o.viewport.split("x").map(Number);
|
|
31
|
+
const pace = Number(o.pace);
|
|
32
|
+
|
|
33
|
+
// a cursor the recording can see: a dot that follows the mouse and rings when it presses
|
|
34
|
+
const CURSOR = `addEventListener("DOMContentLoaded", () => {
|
|
35
|
+
const c = document.createElement("div");
|
|
36
|
+
c.style.cssText = "position:fixed;left:0;top:0;width:18px;height:18px;margin:-9px 0 0 -9px;border-radius:50%;background:rgba(20,20,22,.35);border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35);pointer-events:none;z-index:2147483647;transition:transform 120ms ease-out;transform:translate(-40px,-40px)";
|
|
37
|
+
document.documentElement.appendChild(c);
|
|
38
|
+
let x = -40, y = -40, down = false;
|
|
39
|
+
const paint = () => (c.style.transform = "translate(" + x + "px," + y + "px) scale(" + (down ? 0.75 : 1) + ")");
|
|
40
|
+
addEventListener("mousemove", (e) => { x = e.clientX; y = e.clientY; paint(); }, true);
|
|
41
|
+
addEventListener("mousedown", () => { down = true; paint(); }, true);
|
|
42
|
+
addEventListener("mouseup", () => { down = false; paint(); }, true);
|
|
43
|
+
});`;
|
|
44
|
+
|
|
45
|
+
(async () => {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), "clip-"));
|
|
47
|
+
const browser = await chromium.launch();
|
|
48
|
+
try {
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
const context = await browser.newContext({ viewport: { width, height }, colorScheme: o.scheme, ...(o.locale && { locale: o.locale }), recordVideo: { dir, size: { width, height } } });
|
|
51
|
+
await context.addInitScript(CURSOR);
|
|
52
|
+
const page = await context.newPage();
|
|
53
|
+
await page.goto(o.url, { waitUntil: "networkidle" });
|
|
54
|
+
// the recording starts with the empty page; the clip starts once this one is ready
|
|
55
|
+
const start = Math.max(0, (Date.now() - t0) / 1000 - 0.2);
|
|
56
|
+
let mx = width / 2, my = height / 2;
|
|
57
|
+
const glide = async (x, y) => { await page.mouse.move(x, y, { steps: Math.max(8, Math.round(Math.hypot(x - mx, y - my) / 18)) }); mx = x; my = y; };
|
|
58
|
+
const at = async (sel) => {
|
|
59
|
+
const el = page.locator(sel).first();
|
|
60
|
+
await el.scrollIntoViewIfNeeded();
|
|
61
|
+
const box = await el.boundingBox();
|
|
62
|
+
if (!box) throw new Error(`"${sel}" is not on the page`);
|
|
63
|
+
return [box.x + box.width / 2, box.y + box.height / 2];
|
|
64
|
+
};
|
|
65
|
+
await page.mouse.move(mx, my);
|
|
66
|
+
await page.waitForTimeout(pace);
|
|
67
|
+
for (const step of o.do) {
|
|
68
|
+
const [kind, ...rest] = step.split(":");
|
|
69
|
+
const arg = rest.join(":");
|
|
70
|
+
const eq = arg.indexOf("=");
|
|
71
|
+
if (kind === "click" || kind === "hover") {
|
|
72
|
+
await glide(...(await at(arg)));
|
|
73
|
+
await page.waitForTimeout(150);
|
|
74
|
+
if (kind === "click") await page.mouse.click(mx, my);
|
|
75
|
+
} else if (kind === "type") {
|
|
76
|
+
await glide(...(await at(arg.slice(0, eq))));
|
|
77
|
+
await page.mouse.click(mx, my);
|
|
78
|
+
await page.locator(arg.slice(0, eq)).first().fill("");
|
|
79
|
+
await page.keyboard.type(arg.slice(eq + 1), { delay: 55 });
|
|
80
|
+
} else if (kind === "drag") {
|
|
81
|
+
await glide(...(await at(arg.slice(0, eq))));
|
|
82
|
+
await page.mouse.down();
|
|
83
|
+
const [x, y] = await at(arg.slice(eq + 1));
|
|
84
|
+
await glide(x, y);
|
|
85
|
+
await page.mouse.up();
|
|
86
|
+
} else if (kind === "press") await page.keyboard.press(arg);
|
|
87
|
+
else if (kind === "scroll") await page.mouse.wheel(0, Number(arg));
|
|
88
|
+
else if (kind === "wait") await page.waitForTimeout(Number(arg));
|
|
89
|
+
else throw new Error(`unknown action "${kind}": use click, hover, type, drag, press, scroll or wait`);
|
|
90
|
+
await page.waitForTimeout(pace);
|
|
91
|
+
}
|
|
92
|
+
await page.waitForTimeout(1200);
|
|
93
|
+
const video = page.video();
|
|
94
|
+
await context.close();
|
|
95
|
+
await video.saveAs(join(shots, `${o.out}.webm`));
|
|
96
|
+
console.log(`shots/${o.out}.webm#t=${start.toFixed(2)}`);
|
|
97
|
+
} finally {
|
|
98
|
+
await browser.close();
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
})().catch((e) => {
|
|
102
|
+
console.error(e.message);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Takes a screenshot of a running instance into ~/review/shots, for <Shot> and <Compare>.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/shot.cjs --url http://localhost:7781/settings --out base-settings
|
|
5
|
+
// [--selector ".zoom-actions"] [--pad 24] crop to an element, with some room around it
|
|
6
|
+
// [--highlight ".zoom-actions"] outline an element before the shot
|
|
7
|
+
// [--do "click:button.save" --do "fill:#name=Ada" --do "press:Enter" --do "hover:.menu" --do "wait:500"]
|
|
8
|
+
// [--viewport 1280x800] [--scale 2] [--scheme light|dark] [--locale he-IL] [--full]
|
|
9
|
+
//
|
|
10
|
+
// Prints the path to put in src (shots/<out>.png).
|
|
11
|
+
const { parseArgs } = require("node:util");
|
|
12
|
+
const { mkdirSync } = require("node:fs");
|
|
13
|
+
const { join, resolve } = require("node:path");
|
|
14
|
+
const { chromium } = require("playwright");
|
|
15
|
+
|
|
16
|
+
const { values: o } = parseArgs({
|
|
17
|
+
options: {
|
|
18
|
+
url: { type: "string" }, out: { type: "string" }, selector: { type: "string" }, pad: { type: "string", default: "16" },
|
|
19
|
+
highlight: { type: "string" }, do: { type: "string", multiple: true, default: [] }, viewport: { type: "string", default: "1280x800" },
|
|
20
|
+
scale: { type: "string", default: "2" }, scheme: { type: "string", default: "light" }, locale: { type: "string" }, full: { type: "boolean", default: false },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!o.url || !o.out) {
|
|
24
|
+
console.error("usage: shot.cjs --url <url> --out <name> [--selector css] [--highlight css] [--do action]... (see the top of this file)");
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const shots = resolve(__dirname, "../../shots");
|
|
29
|
+
mkdirSync(shots, { recursive: true });
|
|
30
|
+
const [width, height] = o.viewport.split("x").map(Number);
|
|
31
|
+
|
|
32
|
+
(async () => {
|
|
33
|
+
const browser = await chromium.launch();
|
|
34
|
+
try {
|
|
35
|
+
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: Number(o.scale), colorScheme: o.scheme, ...(o.locale && { locale: o.locale }) });
|
|
36
|
+
await page.goto(o.url, { waitUntil: "networkidle" });
|
|
37
|
+
for (const step of o.do) {
|
|
38
|
+
const [kind, ...rest] = step.split(":");
|
|
39
|
+
const arg = rest.join(":");
|
|
40
|
+
if (kind === "click") await page.click(arg);
|
|
41
|
+
else if (kind === "hover") await page.hover(arg);
|
|
42
|
+
else if (kind === "press") await page.keyboard.press(arg);
|
|
43
|
+
else if (kind === "wait") await page.waitForTimeout(Number(arg));
|
|
44
|
+
else if (kind === "fill") {
|
|
45
|
+
const i = arg.indexOf("=");
|
|
46
|
+
await page.fill(arg.slice(0, i), arg.slice(i + 1));
|
|
47
|
+
} else throw new Error(`unknown action "${kind}": use click, hover, press, wait or fill`);
|
|
48
|
+
}
|
|
49
|
+
await page.waitForLoadState("networkidle").catch(() => {});
|
|
50
|
+
if (o.highlight) await page.locator(o.highlight).first().evaluate((el) => { el.style.outline = "2px solid #eb5d5d"; el.style.outlineOffset = "3px"; });
|
|
51
|
+
const file = join(shots, `${o.out}.png`);
|
|
52
|
+
if (o.selector) {
|
|
53
|
+
const box = await page.locator(o.selector).first().boundingBox();
|
|
54
|
+
if (!box) throw new Error(`"${o.selector}" is not on the page`);
|
|
55
|
+
const pad = Number(o.pad);
|
|
56
|
+
const x = Math.max(0, box.x - pad), y = Math.max(0, box.y - pad);
|
|
57
|
+
await page.screenshot({ path: file, clip: { x, y, width: Math.min(width - x, box.width + 2 * pad), height: box.height + 2 * pad } });
|
|
58
|
+
} else await page.screenshot({ path: file, fullPage: o.full });
|
|
59
|
+
console.log(`shots/${o.out}.png`);
|
|
60
|
+
} finally {
|
|
61
|
+
await browser.close();
|
|
62
|
+
}
|
|
63
|
+
})().catch((e) => {
|
|
64
|
+
console.error(e.message);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The review shell: the rail with every stop of the tour, routing between them, and keyboard navigation.
|
|
2
|
+
// This frame is shared by every review. Leave it as it is unless a PR genuinely needs something else, and say why.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Changes, isReviewed } from "./kit/code";
|
|
5
|
+
import { Finish } from "./kit/finish";
|
|
6
|
+
import { PullRequest } from "./kit/pullrequest";
|
|
7
|
+
import { Footer, type Page } from "./kit/agent";
|
|
8
|
+
import { useLive } from "./kit/live";
|
|
9
|
+
import { pr, shortTitle, total } from "./kit/pr";
|
|
10
|
+
import { hash, NavContext, seenChapters as seen, type Review, type Target } from "./kit/review";
|
|
11
|
+
import { Icon, Stat, ThemeToggle } from "./kit/ui";
|
|
12
|
+
|
|
13
|
+
type Route = { name: "overview" } | { name: "chapter"; id: string } | { name: "changes"; group?: string } | { name: "pr" } | { name: "finish" };
|
|
14
|
+
|
|
15
|
+
function parse(h: string): Route {
|
|
16
|
+
const [, kind, id] = h.replace(/^#\/?/, "").match(/^([^/]*)\/?(.*)$/) ?? [];
|
|
17
|
+
if (kind === "chapter" && id) return { name: "chapter", id: decodeURIComponent(id) };
|
|
18
|
+
if (kind === "changes") return { name: "changes", group: id ? decodeURIComponent(id) : undefined };
|
|
19
|
+
if (kind === "finish") return { name: "finish" };
|
|
20
|
+
if (kind === "pr") return { name: "pr" };
|
|
21
|
+
return { name: "overview" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// the rail folds to a strip of its marks; remembered across visits
|
|
26
|
+
const RAIL_KEY = "review-rail";
|
|
27
|
+
function useRail() {
|
|
28
|
+
const [strip, setStrip] = useState(() => { try { return localStorage.getItem(RAIL_KEY) === "strip"; } catch { return false; } });
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
document.documentElement.toggleAttribute("data-rail-strip", strip);
|
|
31
|
+
try { localStorage.setItem(RAIL_KEY, strip ? "strip" : "full"); } catch { /* private mode: still folds for this visit */ }
|
|
32
|
+
}, [strip]);
|
|
33
|
+
return [strip, () => setStrip((s) => !s)] as const;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function App({ review }: { review: Review }) {
|
|
37
|
+
const [route, setRoute] = useState<Route>(() => parse(location.hash));
|
|
38
|
+
const [, bump] = useState(0);
|
|
39
|
+
const [strip, toggleStrip] = useRail();
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const on = () => setRoute(parse(location.hash));
|
|
42
|
+
const re = () => bump((n) => n + 1);
|
|
43
|
+
addEventListener("hashchange", on);
|
|
44
|
+
addEventListener("reviewed", re);
|
|
45
|
+
return () => (removeEventListener("hashchange", on), removeEventListener("reviewed", re));
|
|
46
|
+
}, []);
|
|
47
|
+
const go = (t: Target) => {
|
|
48
|
+
if (route.name === "chapter") seen.add(route.id);
|
|
49
|
+
location.hash = hash(t);
|
|
50
|
+
};
|
|
51
|
+
const { updates } = useLive();
|
|
52
|
+
const update = updates.at(-1);
|
|
53
|
+
// the pull request page only where there is one
|
|
54
|
+
const order: Target[] = ["overview", ...review.chapters.map((c) => ({ chapter: c.id })), "changes", "finish", ...(pr.number ? ["pr" as const] : [])];
|
|
55
|
+
// every page, for scripts/check.cjs
|
|
56
|
+
(window as any).__REVIEW_ROUTES__ = order.map(hash);
|
|
57
|
+
const index = route.name === "chapter" ? review.chapters.findIndex((c) => c.id === route.id) : -1;
|
|
58
|
+
const at = route.name === "overview" || route.name === "chapter" ? index + 1 : order.indexOf(route.name);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const key = (e: KeyboardEvent) => {
|
|
62
|
+
// ⌘B (Ctrl+B elsewhere) folds the rail, as in most editors
|
|
63
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "b") { e.preventDefault(); toggleStrip(); return; }
|
|
64
|
+
if (e.metaKey || e.ctrlKey || e.altKey || /input|textarea|select/i.test((e.target as HTMLElement).tagName)) return;
|
|
65
|
+
if ((e.key === "j" || e.key === "ArrowRight") && at < order.length - 1) go(order[at + 1]);
|
|
66
|
+
else if ((e.key === "k" || e.key === "ArrowLeft") && at > 0) go(order[at - 1]);
|
|
67
|
+
else if (e.key === "r") document.getElementById("run")?.click();
|
|
68
|
+
else if (e.key === "[") toggleStrip();
|
|
69
|
+
};
|
|
70
|
+
addEventListener("keydown", key);
|
|
71
|
+
return () => removeEventListener("keydown", key);
|
|
72
|
+
});
|
|
73
|
+
useEffect(() => { document.querySelector("main .content")?.scrollTo(0, 0); }, [route.name, route.name === "chapter" && route.id]);
|
|
74
|
+
|
|
75
|
+
const chapter = index >= 0 ? { def: review.chapters[index], index } : undefined;
|
|
76
|
+
const group = route.name === "changes" && route.group ? review.groups.find((g) => g.id === route.group) : undefined;
|
|
77
|
+
const page: Page = chapter
|
|
78
|
+
? { label: `chapter ${chapter.index + 1}, ${chapter.def.title}`, short: `Chapter ${chapter.index + 1}`, chapter: chapter.def.id }
|
|
79
|
+
: route.name === "overview" ? { label: "the overview", short: "Overview" }
|
|
80
|
+
: route.name === "finish" ? { label: "the finish page", short: "Finish" }
|
|
81
|
+
: route.name === "pr" ? { label: "the pull request page", short: "Pull request" }
|
|
82
|
+
: { label: group ? `the changes, ${group.title}` : "the changes", short: "Changes" };
|
|
83
|
+
const Item = ({ to, current, mark, done, sub, title, children }: { to: Target; current: boolean; mark: React.ReactNode; done?: boolean; sub?: boolean; title: string; children: React.ReactNode }) => (
|
|
84
|
+
<button className={`${done ? "seen" : ""}${sub ? " sub" : ""}`} aria-current={current} onClick={() => go(to)} title={strip ? title : undefined} aria-label={strip ? title : undefined}>
|
|
85
|
+
<span className="mk">{done ? Icon.check : mark}</span>
|
|
86
|
+
<span>{children}</span>
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<NavContext.Provider value={{ review, go, chapter }}>
|
|
92
|
+
<nav className="rail" aria-label="Review">
|
|
93
|
+
<div className="pr">
|
|
94
|
+
{pr.url ? <a className="r" href={pr.url} target="_blank" rel="noreferrer">{Icon.pr} {pr.repo} {pr.ref}</a> : <span className="r">{Icon.pr} {pr.repo} {"\u00b7"} {pr.ref}</span>}
|
|
95
|
+
<b>{shortTitle}</b>
|
|
96
|
+
{update && <span className="update" title={update.note}>{update.refreshed ? `Updated: ${update.note}` : `Updating the tour: ${update.note}`}</span>}
|
|
97
|
+
</div>
|
|
98
|
+
<Item to="overview" current={route.name === "overview"} mark="" title="Overview">Overview</Item>
|
|
99
|
+
{review.chapters.length > 0 && <div className="sh">Tour</div>}
|
|
100
|
+
{review.chapters.map((c, i) => (
|
|
101
|
+
<Item key={c.id} to={{ chapter: c.id }} current={route.name === "chapter" && route.id === c.id} mark={i + 1} done={seen.has(c.id)} title={c.title}>{c.title}</Item>
|
|
102
|
+
))}
|
|
103
|
+
<div className="sh">Code</div>
|
|
104
|
+
<Item to="changes" current={route.name === "changes" && !route.group} mark="" title="Changes">Changes <Stat {...total} /></Item>
|
|
105
|
+
{route.name === "changes" &&
|
|
106
|
+
review.groups.map((g, i) => (
|
|
107
|
+
<Item key={g.id} sub to={{ group: g.id }} current={route.group === g.id} mark="" done={isReviewed(g.id)} title={g.title}>{g.title}</Item>
|
|
108
|
+
))}
|
|
109
|
+
<Item to="finish" current={route.name === "finish"} mark="" title="Finish review">Finish review</Item>
|
|
110
|
+
{pr.number > 0 && <Item to="pr" current={route.name === "pr"} mark="" title="Pull request">Pull request</Item>}
|
|
111
|
+
<div className="gap" />
|
|
112
|
+
<div className="rail-foot">
|
|
113
|
+
<button className="icon-btn" onClick={toggleStrip} title={`${strip ? "Show" : "Fold"} the sidebar (${navigator.platform.startsWith("Mac") ? "⌘B" : "Ctrl+B"})`} aria-label={strip ? "Show the sidebar" : "Fold the sidebar"} aria-pressed={strip}>{Icon.panel}</button>
|
|
114
|
+
<ThemeToggle />
|
|
115
|
+
</div>
|
|
116
|
+
</nav>
|
|
117
|
+
<main>
|
|
118
|
+
<div className="main-row">
|
|
119
|
+
<div className="content">
|
|
120
|
+
{route.name === "overview" && review.overview()}
|
|
121
|
+
{route.name === "chapter" && (chapter ? <div key={chapter.def.id} style={{ height: "100%" }}>{chapter.def.page()}</div> : <p className="empty">No chapter "{route.id}".</p>)}
|
|
122
|
+
{route.name === "changes" && <Changes focus={route.group} />}
|
|
123
|
+
{route.name === "pr" && <PullRequest />}
|
|
124
|
+
{route.name === "finish" && <Finish />}
|
|
125
|
+
</div>
|
|
126
|
+
<aside className="dock" id="agent-dock" />
|
|
127
|
+
</div>
|
|
128
|
+
<Footer page={page} />
|
|
129
|
+
</main>
|
|
130
|
+
</NavContext.Provider>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"base": "44a296d4de65a4fc955aa6c2e5df4133f81b4497", "head": "d1d74d5d283e5df930a332001b7048e97b48e5f7"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
diff --git a/packages/server/src/routes/unfurl.ts b/packages/server/src/routes/unfurl.ts
|
|
2
|
+
index f290720..ea57dec 100644
|
|
3
|
+
--- a/packages/server/src/routes/unfurl.ts
|
|
4
|
+
+++ b/packages/server/src/routes/unfurl.ts
|
|
5
|
+
@@ -35,6 +35,7 @@ async function fetchPreview(url: string): Promise<LinkPreview> {
|
|
6
|
+
let current = url;
|
|
7
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
8
|
+
await assertPublic(new URL(current).hostname);
|
|
9
|
+
+ reviewHook("unfurl.ts:fetch", `server fetches ${current}`, { id: "ssrf", edge: ["guard", "internal"], node: "internal", status: "fail", label: "reached" });
|
|
10
|
+
const res = await fetch(current, {
|
|
11
|
+
redirect: "manual",
|
|
12
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
13
|
+
@@ -73,6 +74,7 @@ export function fallbackPreview(url: string, pageUrl: string, icon?: string | nu
|
|
14
|
+
async function assertPublic(hostname: string) {
|
|
15
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
16
|
+
const addresses = isIP(host) ? [host] : (await lookup(host, { all: true })).map((a) => a.address);
|
|
17
|
+
+ { const blocked = addresses.length === 0 || addresses.some(isPrivate); reviewHook("unfurl.ts:assertPublic", `guard checked ${host} -> ${blocked ? "blocked" : "allowed"}`, { id: "ssrf", edge: ["route", "guard"], node: "guard", status: blocked ? "fail" : "ok", label: blocked ? "blocked" : "public?" }); }
|
|
18
|
+
if (addresses.length === 0 || addresses.some(isPrivate)) throw new Error("Private address");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@@ -197,3 +199,7 @@ function clean(value: string | null) {
|
|
22
|
+
.trim();
|
|
23
|
+
return text ? text.slice(0, 300) : null;
|
|
24
|
+
}
|
|
25
|
+
+
|
|
26
|
+
+function reviewHook(at: string, text: string, flow: object) {
|
|
27
|
+
+ fetch(process.env.BOTTLESHIP_REVIEW_URL + "/api/events", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ instance: process.env.BOTTLESHIP_REVIEW_INSTANCE, kind: "hook", at, text, flow }) }).catch(() => {});
|
|
28
|
+
+}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
diff --git a/packages/server/src/routes/unfurl.ts b/packages/server/src/routes/unfurl.ts
|
|
2
|
+
index 197044a..f290720 100644
|
|
3
|
+
--- a/packages/server/src/routes/unfurl.ts
|
|
4
|
+
+++ b/packages/server/src/routes/unfurl.ts
|
|
5
|
+
@@ -76,14 +76,53 @@ async function assertPublic(hostname: string) {
|
|
6
|
+
if (addresses.length === 0 || addresses.some(isPrivate)) throw new Error("Private address");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
-function isPrivate(address: string) {
|
|
10
|
+
- const v4 = address.replace(/^::ffff:/, "");
|
|
11
|
+
- if (isIP(v4) === 4) {
|
|
12
|
+
- const [a, b] = v4.split(".").map(Number) as [number, number];
|
|
13
|
+
- return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || a >= 224;
|
|
14
|
+
+export function isPrivate(address: string) {
|
|
15
|
+
+ const v4 = toIPv4(address);
|
|
16
|
+
+ if (v4) return isPrivateV4(v4);
|
|
17
|
+
+ const groups = toIPv6Groups(address);
|
|
18
|
+
+ // An address this cannot parse is not proven public, so block it too.
|
|
19
|
+
+ if (!groups) return true;
|
|
20
|
+
+ const [a, b, c, d, e, f] = groups;
|
|
21
|
+
+ return (
|
|
22
|
+
+ (a & 0xfe00) === 0xfc00 || // fc00::/7 unique local
|
|
23
|
+
+ (a & 0xffc0) === 0xfe80 || // fe80::/10 link local
|
|
24
|
+
+ (a & 0xff00) === 0xff00 || // ff00::/8 multicast
|
|
25
|
+
+ (a === 0x64 && b === 0xff9b && c === 0 && d === 0 && e === 0 && f === 0) // 64:ff9b::/96 NAT64
|
|
26
|
+
+ );
|
|
27
|
+
+}
|
|
28
|
+
+
|
|
29
|
+
+function isPrivateV4(v4: string) {
|
|
30
|
+
+ const [a, b] = v4.split(".").map(Number) as [number, number];
|
|
31
|
+
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || a >= 224;
|
|
32
|
+
+}
|
|
33
|
+
+
|
|
34
|
+
+// The IPv4 address an IPv6 address embeds (::/96 and ::ffff:0:0/96), or null.
|
|
35
|
+
+function toIPv4(address: string): string | null {
|
|
36
|
+
+ if (isIP(address) === 4) return address;
|
|
37
|
+
+ const groups = toIPv6Groups(address);
|
|
38
|
+
+ if (!groups) return null;
|
|
39
|
+
+ const [a, b, c, d, e, f, hi, lo] = groups;
|
|
40
|
+
+ if (a || b || c || d || e || (f !== 0 && f !== 0xffff)) return null;
|
|
41
|
+
+ return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
42
|
+
+}
|
|
43
|
+
+
|
|
44
|
+
+// The eight 16-bit groups of an IPv6 address, or null when it is not one.
|
|
45
|
+
+function toIPv6Groups(address: string): number[] | null {
|
|
46
|
+
+ let src = address;
|
|
47
|
+
+ const dotted = address.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
48
|
+
+ if (dotted) {
|
|
49
|
+
+ const octets = dotted[1].split(".").map(Number);
|
|
50
|
+
+ if (octets.some((octet) => octet > 255)) return null;
|
|
51
|
+
+ src = `${address.slice(0, dotted.index)}${(((octets[0] << 8) | octets[1]) >>> 0).toString(16)}:${(((octets[2] << 8) | octets[3]) >>> 0).toString(16)}`;
|
|
52
|
+
}
|
|
53
|
+
- const v6 = address.toLowerCase();
|
|
54
|
+
- return v6 === "::" || v6 === "::1" || v6.startsWith("fc") || v6.startsWith("fd") || v6.startsWith("fe80");
|
|
55
|
+
+ const parts = src.split("::");
|
|
56
|
+
+ if (parts.length > 2) return null;
|
|
57
|
+
+ const parse = (part: string) => part.split(":").map((group) => (group === "" ? NaN : parseInt(group, 16)));
|
|
58
|
+
+ const high = parts[0] === "" ? [] : parse(parts[0]);
|
|
59
|
+
+ const low = parts.length === 1 || parts[1] === "" ? [] : parse(parts[1]);
|
|
60
|
+
+ if (parts.length === 2 && high.length + low.length > 8) return null;
|
|
61
|
+
+ const groups = parts.length === 1 ? high : [...high, ...Array(8 - high.length - low.length).fill(0), ...low];
|
|
62
|
+
+ return groups.length === 8 && groups.every((group) => Number.isInteger(group) && group >= 0 && group <= 0xffff) ? groups : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readStart(res: Response) {
|
|
66
|
+
diff --git a/packages/server/test/unfurl.test.ts b/packages/server/test/unfurl.test.ts
|
|
67
|
+
index 84154f4..6c04e6a 100644
|
|
68
|
+
--- a/packages/server/test/unfurl.test.ts
|
|
69
|
+
+++ b/packages/server/test/unfurl.test.ts
|
|
70
|
+
@@ -1,6 +1,8 @@
|
|
71
|
+
import assert from "node:assert/strict";
|
|
72
|
+
+import { createServer } from "node:http";
|
|
73
|
+
+import type { AddressInfo } from "node:net";
|
|
74
|
+
import { test } from "node:test";
|
|
75
|
+
-import { fallbackPreview, parsePreview } from "../src/routes/unfurl.ts";
|
|
76
|
+
+import { fallbackPreview, isPrivate, parsePreview } from "../src/routes/unfurl.ts";
|
|
77
|
+
import { setup } from "./helpers.ts";
|
|
78
|
+
|
|
79
|
+
test("previews read open graph tags and fall back to title and favicon", () => {
|
|
80
|
+
@@ -44,6 +46,34 @@ test("unfurl refuses private addresses and non-http URLs", async () => {
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
+test("private addresses cover IPv4-mapped forms and the full IPv6 ranges", () => {
|
|
85
|
+
+ for (const address of ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::", "::ffff:7f00:1", "::ffff:127.0.0.1", "::ffff:a9fe:a9fe", "0:0:0:0:0:ffff:7f00:1", "fe80::1", "fe90::1", "febf::1", "fc00::1", "ff02::1", "64:ff9b::8.8.8.8"]) {
|
|
86
|
+
+ assert.equal(isPrivate(address), true, address);
|
|
87
|
+
+ }
|
|
88
|
+
+ for (const address of ["8.8.8.8", "172.32.0.1", "2606:4700::1111", "::ffff:8.8.8.8", "fbff::1"]) {
|
|
89
|
+
+ assert.equal(isPrivate(address), false, address);
|
|
90
|
+
+ }
|
|
91
|
+
+});
|
|
92
|
+
+
|
|
93
|
+
+test("unfurl refuses an IPv4-mapped loopback host", async () => {
|
|
94
|
+
+ const server = createServer((_req, res) => {
|
|
95
|
+
+ res.writeHead(200, { "content-type": "text/html" });
|
|
96
|
+
+ res.end("<title>internal</title>");
|
|
97
|
+
+ });
|
|
98
|
+
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
99
|
+
+ try {
|
|
100
|
+
+ const port = (server.address() as AddressInfo).port;
|
|
101
|
+
+ const { request, ownedChannel } = await setup();
|
|
102
|
+
+ const { human } = await ownedChannel();
|
|
103
|
+
+ const { status, json } = await request("GET", `/unfurl?url=${encodeURIComponent(`http://[::ffff:7f00:1]:${port}/`)}`, { token: human });
|
|
104
|
+
+ assert.equal(status, 200);
|
|
105
|
+
+ assert.equal(json.title, null);
|
|
106
|
+
+ } finally {
|
|
107
|
+
+ server.closeAllConnections();
|
|
108
|
+
+ server.close();
|
|
109
|
+
+ }
|
|
110
|
+
+});
|
|
111
|
+
+
|
|
112
|
+
test("pages that can't be read still get a favicon, and GitHub links a title", () => {
|
|
113
|
+
assert.deepEqual(fallbackPreview("https://github.com/acme/app/pull/15", "https://github.com/acme/app/pull/15"), {
|
|
114
|
+
url: "https://github.com/acme/app/pull/15",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": {
|
|
3
|
+
"is_bot": true,
|
|
4
|
+
"login": "app/polylane"
|
|
5
|
+
},
|
|
6
|
+
"baseRefName": "main",
|
|
7
|
+
"baseRefOid": "44a296d4de65a4fc955aa6c2e5df4133f81b4497",
|
|
8
|
+
"body": "**Fixes:** [rply link-preview SSRF guard lets IPv4-mapped IPv6 hosts reach private addresses](https://console.polylane.com/joshuas-workspace-dnkxla/threads/thrd_17a0379e4001b2nxxdsn59kw?ref=github.autofix-pr)\n\nThe link-preview guard let an authenticated caller make the server fetch addresses it meant to block. A URL whose host is an IPv4-mapped IPv6 address such as `::ffff:7f00:1` passed the private-address check, so the server connected to loopback, link-local, or cloud metadata addresses. The guard now parses each address and rejects the full loopback, link-local, unique-local, multicast, mapped, and NAT64 ranges before it fetches.\n\n```mermaid\nflowchart LR\n A[\"Attacker URL host<br/>[::ffff:7f00:1]\"] --> B[\"assertPublic(hostname)\"]\n B --> C[\"isPrivate(address)\"]\n C --> D[\"fetch(hostname)\"]\n D --> E[\"loopback or metadata reachable<br/>observed pre-fix\"]\n C -. \"fix: parse groups and check full ranges\" .-> F[\"reject before fetch\"]\n```\n\n<details>\n<summary>Detection evidence</summary>\n\n**Affected:** `int_179ff2f29001vt0jk49fzx9f`\n\n</details>\n\n## Why this fix\n\nThe guard's `isPrivate` stripped only the dotted `::ffff:` prefix and checked only `::`, `::1`, and the `fc`, `fd`, `fe80` prefixes. WHATWG URL serializes a mapped literal as hex, so `http://[::ffff:127.0.0.1]/` became `[::ffff:7f00:1]`, tested public, and was fetched. I reproduced this with a server listening only on 127.0.0.1: the pre-fix code returned its page. Parsing the address into groups and checking the full ranges classifies all 15 private test addresses as private and keeps the 5 public ones public.\n\n`assertPublic` and the fetch still resolve the host separately, so a DNS record that changes between them still bypasses the guard. That separate weakness is recorded on the issue timeline and is not addressed here.\n\n**Production impact: Link-preview guard rejects more private addresses; no deploy path affected**\n\nThe change edits one request-path guard in the rply server (`packages/server/src/routes/unfurl.ts`). It adds no external call, schema change, binding, or environment prerequisite, and it only narrows which hostnames the route may fetch. The workspace graph records no deployed resource for `joshuaKnauber/rply` (`getNodeEdges` on the repository node returned zero edges), so no production resource behavior changes beyond the guard. The repo-wide typecheck and the full server suite (60/60) pass.\n\nChecked and not affected: `provider#code#account#joshuaKnauber#region#earth#type#code.repository#id#joshuaKnauber/rply`.\n\n**Observability**\n\n- `fetchPreview` throws `Private address`, caught by the route, which returns the fallback preview with `title: null`; the existing test asserts this.\n- no new log or metric is added; the change relies on the existing route response.\n\n_Polylane assessed this pull request at `d1d74d5`._\n<!-- polylane:autofix-video -->\n<!-- polylane:autofix-video-attachment page_17a09b709001v6eo2xf2el31xs3sg0t7b277nkbb 1793049633417 -->\n## Fix walkthrough\n\n[](https://console.polylane.com/pages/page_17a09b709001v6eo2xf2el31xs3sg0t7b277nkbb?workspace=joshuas-workspace-dnkxla#video)\n\n[Watch the fix explained (1:26)](https://console.polylane.com/pages/page_17a09b709001v6eo2xf2el31xs3sg0t7b277nkbb?workspace=joshuas-workspace-dnkxla#video). Recorded for commit `d1d74d5d283e5df930a332001b7048e97b48e5f7`.\n<!-- /polylane:autofix-video -->\n\n\n<details>\n<summary>Causal chain</summary>\n\n- **Signal** (alert): rply link-preview SSRF check misses IPv4-mapped IPv6 addresses\n- **Surfacing site**: rply link-preview route at joshuaKnauber/rply:packages/server/src/routes/unfurl.ts#isPrivate\n- **Mechanism**: assertPublic passes the host to isPrivate, which strips only the dotted '::ffff:' prefix and checks only ::, ::1, fc, fd, fe80, so a hex IPv4-mapped address such as ::ffff:7f00:1 (WHATWG URL's serialization of a mapped literal) tests as public and fetchPreview fetches it.\n- **Producer**: rply link-preview guard (isPrivate), at joshuaKnauber/rply:packages/server/src/routes/unfurl.ts#isPrivate\n- **Trigger**: An authenticated member requests GET /v1/unfurl?url=http://[::ffff:7f00:1]:PORT/ (or a host whose AAAA record is ::ffff:a9fe:a9fe for 169.254.169.254).\n- **What happens to the failed unit today**: assertPublic resolves the host and rejects when any address is private; the guard and the fetch resolve the host separately, so they are not pinned to the same address.\n- **Blast radius**: unknown other resource(s), unknown other tenant(s); data at risk: Internal HTTP services and cloud instance metadata (169.254.169.254) behind the server's network become reachable; any credentials or data they return can be read.\n- **Producer evidence**:\n - packages/server/src/routes/unfurl.ts#isPrivate only stripped the dotted mapped prefix and checked a small IPv6 prefix set\n - new URL('http://[::ffff:127.0.0.1]/').hostname === '[::ffff:7f00:1]' on Node v24.22.1\n - fetch('http://[::ffff:7f00:1]:PORT/') reached a server listening only on 127.0.0.1 and returned its HTML title (200)\n\n</details>\n\n<details>\n<summary>2 files changed (+77/-8)</summary>\n\n- `packages/server/src/routes/unfurl.ts`: modified, +46/-7\n- `packages/server/test/unfurl.test.ts`: modified, +31/-1\n\n</details>\n\n<a href=\"https://console.polylane.com/joshuas-workspace-dnkxla/threads/thrd_17a0379e4001b2nxxdsn59kw?ref=github.autofix-pr\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://badges.polylane.com/view-investigation/dark.svg?v=11&face=1\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://badges.polylane.com/view-investigation/light.svg?v=11&face=1\"><img alt=\"View thread\" src=\"https://badges.polylane.com/view-investigation/light.svg?v=11&face=1\"></picture></a> <a href=\"https://console.polylane.com/pages/page_17a09716d001eri4m7b59blqagt2xbtffx432q86?workspace=joshuas-workspace-dnkxla&ref=github.autofix-pr\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://badges.polylane.com/view-autofix/dark.svg?v=11\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://badges.polylane.com/view-autofix/light.svg?v=11\"><img alt=\"View autofix\" src=\"https://badges.polylane.com/view-autofix/light.svg?v=11\"></picture></a>\n\n---\n\nGenerated by [Polylane](https://polylane.com/?ref=github.autofix-pr). You can ask follow-ups by mentioning @polylane in a comment.",
|
|
9
|
+
"comments": [
|
|
10
|
+
{
|
|
11
|
+
"id": "IC_kwDOVDnhhs8AAAABfy7Izg",
|
|
12
|
+
"author": {
|
|
13
|
+
"login": "cloudflare-workers-and-pages"
|
|
14
|
+
},
|
|
15
|
+
"authorAssociation": "NONE",
|
|
16
|
+
"body": "## 🚀 Deploying Preview to Cloudflare 🚀\n<!-- Preview URL -->\n\n<!-- Preview Deployments -->\n### Preview Deployments by commit\n\n<table>\n <thead>\n <tr>\n <th align=\"left\">Status</th>\n <th align=\"left\">Deployment URL</th>\n <th align=\"left\">Commit</th>\n <th align=\"left\">Updated (UTC)</th>\n <th align=\"left\">See this deployment's details</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td><ul><li><b>Build:</b> Failed ❌</li></ul><br/>\n <a href=\"https://dash.cloudflare.com/?to=/995b3d1a95046253b187262331316150/workers/services/view/rply/production/builds/09b47568-1586-44f5-b914-de34471b4f23\">View logs ↗</a></td>\n <td></td>\n <td>d1d74d5</td>\n <td>2026-10-26T21:07:06.595Z</td>\n <td><a href=\"https://dash.cloudflare.com/?to=/995b3d1a95046253b187262331316150/workers/services/view/rply/production/builds/09b47568-1586-44f5-b914-de34471b4f23\">View logs ↗</a></td>\n </tr>\n </tbody>\n</table>\n",
|
|
17
|
+
"createdAt": "2026-10-26T22:22:42Z",
|
|
18
|
+
"includesCreatedEdit": false,
|
|
19
|
+
"isMinimized": false,
|
|
20
|
+
"minimizedReason": "",
|
|
21
|
+
"reactionGroups": [
|
|
22
|
+
{
|
|
23
|
+
"content": "EYES",
|
|
24
|
+
"users": {
|
|
25
|
+
"totalCount": 1
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"url": "https://github.com/joshuaKnauber/rply/pull/16#issuecomment-6428739790",
|
|
30
|
+
"viewerDidAuthor": false
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"files": [
|
|
34
|
+
{
|
|
35
|
+
"path": "packages/server/src/routes/unfurl.ts",
|
|
36
|
+
"additions": 46,
|
|
37
|
+
"deletions": 7,
|
|
38
|
+
"changeType": "MODIFIED"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "packages/server/test/unfurl.test.ts",
|
|
42
|
+
"additions": 31,
|
|
43
|
+
"deletions": 1,
|
|
44
|
+
"changeType": "MODIFIED"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"headRefName": "polylane/autofix/kl2pj4cba6xv",
|
|
48
|
+
"headRefOid": "d1d74d5d283e5df930a332001b7048e97b48e5f7",
|
|
49
|
+
"labels": [
|
|
50
|
+
{
|
|
51
|
+
"id": "LA_kwDOVDnhhs8AAAAC_b_ssw",
|
|
52
|
+
"name": "polylane",
|
|
53
|
+
"description": "",
|
|
54
|
+
"color": "ededed"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "LA_kwDOVDnhhs8AAAAC_cDnwA",
|
|
58
|
+
"name": "severity:medium",
|
|
59
|
+
"description": "Polylane autofix severity: medium",
|
|
60
|
+
"color": "fbca04"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"number": 16,
|
|
64
|
+
"reviews": [],
|
|
65
|
+
"state": "OPEN",
|
|
66
|
+
"title": "fix(server): reject IPv4-mapped and reserved IPv6 hosts in link previews",
|
|
67
|
+
"url": "https://github.com/joshuaKnauber/rply/pull/16"
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|