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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "review",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"check": "tsc --noEmit && vite build && node scripts/check.cjs",
|
|
9
|
+
"playground": "vite --config playground/vite.config.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "19.1.10",
|
|
13
|
+
"react-dom": "19.1.10",
|
|
14
|
+
"react-markdown": "10.1.0",
|
|
15
|
+
"remark-gfm": "4.0.1"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "19.3.0",
|
|
19
|
+
"@types/react-dom": "19.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
21
|
+
"typescript": "5.9.3",
|
|
22
|
+
"vite": "8.3.5"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"base": "dd2f8fc32b92cbf56f4abee6a0cf3b14681b4b55", "head": "d668f6a2a1603723c2187225fd27b185b30bf920"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss
|
|
2
|
+
index 954d0b51128b..964fa10c244b 100644
|
|
3
|
+
--- a/packages/excalidraw/components/Actions.scss
|
|
4
|
+
+++ b/packages/excalidraw/components/Actions.scss
|
|
5
|
+
@@ -5,6 +5,10 @@
|
|
6
|
+
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
+.zoom-actions {
|
|
10
|
+
+ direction: ltr;
|
|
11
|
+
+}
|
|
12
|
+
+
|
|
13
|
+
.zoom-button,
|
|
14
|
+
.undo-redo-buttons button {
|
|
15
|
+
border-radius: 0 !important;
|
|
16
|
+
@@ -54,10 +58,6 @@
|
|
17
|
+
border-top-left-radius: var(--border-radius-lg) !important;
|
|
18
|
+
border-bottom-left-radius: var(--border-radius-lg) !important;
|
|
19
|
+
|
|
20
|
+
- :root[dir="rtl"] & {
|
|
21
|
+
- transform: scaleX(-1);
|
|
22
|
+
- }
|
|
23
|
+
-
|
|
24
|
+
.ToolIcon__icon {
|
|
25
|
+
border-top-right-radius: 0 !important;
|
|
26
|
+
border-bottom-right-radius: 0 !important;
|
|
27
|
+
@@ -68,10 +68,6 @@
|
|
28
|
+
border-top-right-radius: var(--border-radius-lg) !important;
|
|
29
|
+
border-bottom-right-radius: var(--border-radius-lg) !important;
|
|
30
|
+
|
|
31
|
+
- :root[dir="rtl"] & {
|
|
32
|
+
- transform: scaleX(-1);
|
|
33
|
+
- }
|
|
34
|
+
-
|
|
35
|
+
.ToolIcon__icon {
|
|
36
|
+
border-top-left-radius: 0 !important;
|
|
37
|
+
border-bottom-left-radius: 0 !important;
|
|
38
|
+
diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx
|
|
39
|
+
index b10e855c8964..9e749352b5f9 100644
|
|
40
|
+
--- a/packages/excalidraw/components/Actions.tsx
|
|
41
|
+
+++ b/packages/excalidraw/components/Actions.tsx
|
|
42
|
+
@@ -880,7 +880,7 @@ export const ZoomActions = ({
|
|
43
|
+
renderAction: ActionManager["renderAction"];
|
|
44
|
+
}) => (
|
|
45
|
+
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
|
46
|
+
- <Stack.Row align="center">
|
|
47
|
+
+ <Stack.Row align="center" style={{ direction: "ltr" }}>
|
|
48
|
+
{renderAction("zoomOut")}
|
|
49
|
+
{renderAction("resetZoom")}
|
|
50
|
+
{renderAction("zoomIn")}
|
|
51
|
+
diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss
|
|
52
|
+
index 9a2b66892409..e17afb28479f 100644
|
|
53
|
+
--- a/packages/excalidraw/css/styles.scss
|
|
54
|
+
+++ b/packages/excalidraw/css/styles.scss
|
|
55
|
+
@@ -741,10 +741,6 @@ body.excalidraw-cursor-resize * {
|
|
56
|
+
// shift into the hidden zoom-out button's place
|
|
57
|
+
.reset-zoom-button-wrapper {
|
|
58
|
+
transform: translateX(calc(-1 * var(--lg-button-size)));
|
|
59
|
+
-
|
|
60
|
+
- :root[dir="rtl"] & {
|
|
61
|
+
- transform: translateX(var(--lg-button-size));
|
|
62
|
+
- }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
diff --git a/packages/excalidraw/tests/interactivity.test.tsx b/packages/excalidraw/tests/interactivity.test.tsx
|
|
67
|
+
index fa6e50dd0572..0d3f9ba4dd7e 100644
|
|
68
|
+
--- a/packages/excalidraw/tests/interactivity.test.tsx
|
|
69
|
+
+++ b/packages/excalidraw/tests/interactivity.test.tsx
|
|
70
|
+
@@ -717,6 +717,21 @@ describe("ui={{ enabled: ... }}", () => {
|
|
71
|
+
).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
+ it("preserves LTR direction for zoom controls in RTL locales", async () => {
|
|
75
|
+
+ await render(<Excalidraw langCode="he-IL" />);
|
|
76
|
+
+
|
|
77
|
+
+ const zoomActions = queryContainer(".zoom-actions")!;
|
|
78
|
+
+ expect(zoomActions).not.toBe(null);
|
|
79
|
+
+ const row = zoomActions.querySelector(".Stack_horizontal") as HTMLElement;
|
|
80
|
+
+ expect(row).not.toBe(null);
|
|
81
|
+
+ expect(row.style.direction).toBe("ltr");
|
|
82
|
+
+
|
|
83
|
+
+ const buttons = zoomActions.querySelectorAll("button");
|
|
84
|
+
+ expect(buttons[0].classList.contains("zoom-out-button")).toBe(true);
|
|
85
|
+
+ expect(buttons[1].classList.contains("reset-zoom-button")).toBe(true);
|
|
86
|
+
+ expect(buttons[2].classList.contains("zoom-in-button")).toBe(true);
|
|
87
|
+
+ });
|
|
88
|
+
+
|
|
89
|
+
it("renders an opted-in scroll-back-to-content control", async () => {
|
|
90
|
+
await render(
|
|
91
|
+
<Excalidraw
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": {
|
|
3
|
+
"id": "U_kgDOCp5MVQ",
|
|
4
|
+
"is_bot": false,
|
|
5
|
+
"login": "yashash1111",
|
|
6
|
+
"name": ""
|
|
7
|
+
},
|
|
8
|
+
"baseRefName": "master",
|
|
9
|
+
"baseRefOid": "701b901a0f1ba3d0b390d9cdf63d112e25abac4b",
|
|
10
|
+
"body": "",
|
|
11
|
+
"comments": [
|
|
12
|
+
{
|
|
13
|
+
"id": "IC_kwDODcka_M8AAAABlm7rBA",
|
|
14
|
+
"author": {
|
|
15
|
+
"login": "vercel"
|
|
16
|
+
},
|
|
17
|
+
"authorAssociation": "NONE",
|
|
18
|
+
"body": "[vc]: #o1Usyqwb1sMkGdhOILBPAYCVZX+OMBlSvpLLQMXj5/A=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJleGNhbGlkcmF3IiwicHJvamVjdElkIjoiUW1lRzdGQkJ0YzZzV0ZjUHZZdFNIS1ZucnZFOTl0ZFg4N3cyMlpaTHBBY0pOdCIsInYwIjpmYWxzZSwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2V4Y2FsaWRyYXcvZXhjYWxpZHJhdy84b20zQW9YWnJUVVJZVWNDOG92QVZjVGRuNmZ5IiwicHJldmlld1VybCI6ImV4Y2FsaWRyYXctZ2l0LWZvcmsteWFzaGFzaDExMTEtZml4LXpvb20tYnV0dG9ucy1ydGwtZXhjYWxpZHJhdy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dLCJyZXF1ZXN0UmV2aWV3VXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3ZlcmNlbC1hZ2VudC9yZXF1ZXN0LXJldmlldz9vd25lcj1leGNhbGlkcmF3JnJlcG89ZXhjYWxpZHJhdyZwcj0xMjYwMiJ9\nThe latest updates on your projects. Learn more about [Vercel for GitHub](https://vercel.link/github-learn-more).\n\n| Project | Deployment | Actions | Updated |\n| :--- | :----- | :------ | :------ |\n| <a href=\"https://vercel.com/excalidraw/excalidraw\"><sup><img src=\"https://vercel.com/api/www/avatar?projectId=QmeG7FBBtc6sWFcPvYtSHKVnrvE99tdX87w22ZZLpAcJNt&teamId=team_ya85fAOOrg7PG2amlMCyqH0k&s=32\" width=\"16\" height=\"16\" align=\"middle\" alt=\"\" /></sup></a> [excalidraw](https://vercel.com/excalidraw/excalidraw) |  [Ready](https://vercel.com/excalidraw/excalidraw/8om3AoXZrTURYUcC8ovAVcTdn6fy) | [Preview](https://excalidraw-git-fork-yashash1111-fix-zoom-buttons-rtl-excalidraw.vercel.app) | <relative-time datetime=\"2026-11-12T08:37:21.834Z\">Nov 12, 2026 8:37am UTC</relative-time> |\n\n<a href=\"https://vercel.com/vercel-agent/request-review?owner=excalidraw&repo=excalidraw&pr=12602\" rel=\"noreferrer\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://agents-vade-review.vercel.sh/request-review-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://agents-vade-review.vercel.sh/request-review-light.svg\"><img src=\"https://agents-vade-review.vercel.sh/request-review-light.svg\" alt=\"Request Review\"></picture></a>\n\n",
|
|
19
|
+
"createdAt": "2026-11-12T08:36:07Z",
|
|
20
|
+
"includesCreatedEdit": true,
|
|
21
|
+
"isMinimized": false,
|
|
22
|
+
"minimizedReason": "",
|
|
23
|
+
"reactionGroups": [],
|
|
24
|
+
"url": "https://github.com/excalidraw/excalidraw/pull/12602#issuecomment-6818818820",
|
|
25
|
+
"viewerDidAuthor": false
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
{
|
|
30
|
+
"path": "packages/excalidraw/components/Actions.scss",
|
|
31
|
+
"additions": 4,
|
|
32
|
+
"deletions": 8,
|
|
33
|
+
"changeType": "MODIFIED"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "packages/excalidraw/components/Actions.tsx",
|
|
37
|
+
"additions": 1,
|
|
38
|
+
"deletions": 1,
|
|
39
|
+
"changeType": "MODIFIED"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "packages/excalidraw/css/styles.scss",
|
|
43
|
+
"additions": 0,
|
|
44
|
+
"deletions": 4,
|
|
45
|
+
"changeType": "MODIFIED"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"path": "packages/excalidraw/tests/interactivity.test.tsx",
|
|
49
|
+
"additions": 15,
|
|
50
|
+
"deletions": 0,
|
|
51
|
+
"changeType": "MODIFIED"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"headRefName": "fix/zoom-buttons-rtl",
|
|
55
|
+
"headRefOid": "d668f6a2a1603723c2187225fd27b185b30bf920",
|
|
56
|
+
"labels": [],
|
|
57
|
+
"number": 12602,
|
|
58
|
+
"reviews": [],
|
|
59
|
+
"state": "OPEN",
|
|
60
|
+
"title": "fix(ui): preserve zoom controls order in RTL locales (#12375)",
|
|
61
|
+
"url": "https://github.com/excalidraw/excalidraw/pull/12602"
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": 9001,
|
|
4
|
+
"path": "packages/excalidraw/components/Actions.tsx",
|
|
5
|
+
"line": 883,
|
|
6
|
+
"side": "RIGHT",
|
|
7
|
+
"body": "Actions.scss now sets `direction: ltr` on `.zoom-actions` too. Do we need both? The inline style wins either way.",
|
|
8
|
+
"user": { "login": "dwelle", "type": "User" },
|
|
9
|
+
"created_at": "2026-11-12T09:14:00Z",
|
|
10
|
+
"html_url": "https://github.com/excalidraw/excalidraw/pull/12602#discussion_r9001"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": 9002,
|
|
14
|
+
"path": "packages/excalidraw/components/Actions.tsx",
|
|
15
|
+
"line": 883,
|
|
16
|
+
"side": "RIGHT",
|
|
17
|
+
"in_reply_to_id": 9001,
|
|
18
|
+
"body": "The class covers the wrapper, the inline style the row. I can drop the inline one if you prefer.",
|
|
19
|
+
"user": { "login": "yashash1111", "type": "User" },
|
|
20
|
+
"created_at": "2026-11-12T11:40:00Z"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": 9003,
|
|
24
|
+
"path": "packages/excalidraw/tests/interactivity.test.tsx",
|
|
25
|
+
"line": 732,
|
|
26
|
+
"start_line": 730,
|
|
27
|
+
"side": "RIGHT",
|
|
28
|
+
"start_side": "RIGHT",
|
|
29
|
+
"body": "Checking classes by index is brittle. Maybe:\n\n```suggestion\n const order = [...buttons].map((b) => b.className);\n expect(order).toEqual([expect.stringContaining(\"zoom-out\"), expect.stringContaining(\"reset-zoom\"), expect.stringContaining(\"zoom-in\")]);\n```",
|
|
30
|
+
"user": { "login": "mtolmacs", "type": "User" },
|
|
31
|
+
"created_at": "2026-11-12T10:02:00Z",
|
|
32
|
+
"resolved": true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": 9004,
|
|
36
|
+
"path": "packages/excalidraw/css/styles.scss",
|
|
37
|
+
"line": null,
|
|
38
|
+
"original_line": 744,
|
|
39
|
+
"side": "RIGHT",
|
|
40
|
+
"body": "<!-- review-bot -->\n**Possible regression:** the reset button no longer shifts in RTL. Verify the hidden zoom-out case.",
|
|
41
|
+
"user": { "login": "review-bot[bot]", "type": "Bot" },
|
|
42
|
+
"created_at": "2026-11-11T18:20:00Z"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"rtl-order":{"status":{"id":"rtl-order","status":"done"},"ms":5036,"events":[{"instance":"base","kind":"request","text":"open /?lng=he-IL","step":"rtl-order","seq":1},{"instance":"base","kind":"request","text":"open /?lng=ar-SA","step":"rtl-order","seq":2},{"instance":"base","kind":"fail","text":"he-IL + 100% − · ar-SA + 100% −","overlay":{"status":"fail","title":"Order is reversed","body":"he-IL: left to right: + 100% −\nar-SA: left to right: + 100% −"},"step":"rtl-order","seq":3},{"instance":"head","kind":"request","text":"open /?lng=he-IL","step":"rtl-order","seq":4},{"instance":"head","kind":"request","text":"open /?lng=ar-SA","step":"rtl-order","seq":5},{"instance":"head","kind":"ok","text":"he-IL − 100% + · ar-SA − 100% +","overlay":{"status":"ok","title":"Order is − 100% +","body":"he-IL: left to right: − 100% +\nar-SA: left to right: − 100% +"},"step":"rtl-order","seq":6}]},"rtl-zen":{"status":{"id":"rtl-zen","status":"done"},"ms":5039,"events":[{"instance":"base","kind":"request","text":"open /?lng=he-IL and turn on zen mode","step":"rtl-zen","seq":7},{"instance":"base","kind":"ok","text":"after zen, \"−\" slot is on the right of the \"+\" slot","overlay":{"status":"ok","title":"Zoom level sits in the − slot","body":"he-IL: zen: \"100%\" has its right edge 0px from where \"−\" had it"},"step":"rtl-zen","seq":8},{"instance":"head","kind":"request","text":"open /?lng=he-IL and turn on zen mode","step":"rtl-zen","seq":9},{"instance":"head","kind":"ok","text":"after zen, \"−\" slot is on the left of the \"+\" slot","overlay":{"status":"ok","title":"Zoom level sits in the − slot","body":"he-IL: zen: \"100%\" has its left edge 0px from where \"−\" had it"},"step":"rtl-zen","seq":10}]},"ltr-unchanged":{"status":{"id":"ltr-unchanged","status":"done"},"ms":3015,"events":[{"instance":"base","kind":"request","text":"open /?lng=en-US","step":"ltr-unchanged","seq":11},{"instance":"base","kind":"ok","text":"en-US − 100% +","overlay":{"status":"ok","title":"Order is − 100% +","body":"en-US: left to right: − 100% +"},"step":"ltr-unchanged","seq":12},{"instance":"head","kind":"request","text":"open /?lng=en-US","step":"ltr-unchanged","seq":13},{"instance":"head","kind":"ok","text":"en-US − 100% +","overlay":{"status":"ok","title":"Order is − 100% +","body":"en-US: left to right: − 100% +"},"step":"ltr-unchanged","seq":14}]},"pr-test":{"status":{"id":"pr-test","status":"done"},"ms":6020,"events":[{"instance":"base","kind":"request","text":"vitest -t 'preserves LTR direction'","step":"pr-test","seq":15},{"instance":"base","kind":"fail","text":"Test fails","overlay":{"status":"fail","title":"Test fails","body":"⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected '' to be 'ltr' // Object.is equality "},"step":"pr-test","seq":16},{"instance":"head","kind":"request","text":"vitest -t 'preserves LTR direction'","step":"pr-test","seq":17},{"instance":"head","kind":"ok","text":"Test passes","overlay":{"status":"ok","title":"Test passes","body":" Tests 1 passed | 60 skipped (61) "},"step":"pr-test","seq":18}]}}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "7781": "shots/base-he-page.png", "7782": "shots/head-he-page.png" }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Attention, BeforeAfter, Card, Chapter, Compare, defineReview, Lead, Live, Overview } from "../../../src/kit";
|
|
2
|
+
|
|
3
|
+
const SCSS = "packages/excalidraw/components/Actions.scss";
|
|
4
|
+
const ACTIONS = "packages/excalidraw/components/Actions.tsx";
|
|
5
|
+
const STYLES = "packages/excalidraw/css/styles.scss";
|
|
6
|
+
const TEST = "packages/excalidraw/tests/interactivity.test.tsx";
|
|
7
|
+
|
|
8
|
+
const HE = "/?lng=he-IL";
|
|
9
|
+
|
|
10
|
+
export default defineReview({
|
|
11
|
+
instances: [
|
|
12
|
+
{ id: "base", label: "Base", detail: "master, Hebrew", port: 7781, path: HE },
|
|
13
|
+
{ id: "head", label: "Head", detail: "this PR, Hebrew", port: 7782, path: HE },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
overview: () => (
|
|
17
|
+
<Overview>
|
|
18
|
+
<Lead>In right-to-left languages such as Hebrew and Arabic, the zoom controls in the bottom corner keep their − 100% + order instead of showing + 100% −.</Lead>
|
|
19
|
+
<BeforeAfter
|
|
20
|
+
before={<>The row of zoom buttons followed the page direction, so in an RTL locale it was mirrored: <code>+ 100% −</code>. Corner rounding and the zen-mode shift were patched with <code>scaleX(-1)</code> and an RTL-only <code>translateX</code>.</>}
|
|
21
|
+
after={<>The zoom row is forced to <code>direction: ltr</code>, so it reads <code>− 100% +</code> in every locale. The RTL-only patches are deleted, because the left-to-right rules now apply everywhere.</>}
|
|
22
|
+
/>
|
|
23
|
+
<Compare title="The zoom controls in Hebrew" base="shots/base-he-IL-zoom.png" head="shots/head-he-IL-zoom.png" caption="Base mirrors the controls; head keeps − 100% +." />
|
|
24
|
+
<Attention
|
|
25
|
+
items={[
|
|
26
|
+
{ text: "Zen mode: the lone zoom level must still land in the − slot", detail: "The RTL-only translateX is gone, so the one left-shift now serves both directions. Chapter 2 measures where it ends up.", to: { chapter: "rtl-zen" } },
|
|
27
|
+
{ text: "The direction is set twice", detail: "Both a .zoom-actions CSS rule and an inline style on the inner row force ltr. Either alone would probably do. The inline one is what the test checks.", to: { group: "force-ltr" } },
|
|
28
|
+
{ text: "The new test checks the DOM order, not what is on screen", detail: "It fails on base and passes on head, but it asserts the inline direction and the order of the buttons in the DOM. A CSS change that flips them visually would still pass. Chapter 1 measures the order on screen.", to: { group: "tests" } },
|
|
29
|
+
{ text: "Everything inside the zoom controls is now LTR", detail: "That includes the tooltips shown on hover. They are rendered outside the row, so they were not changed.", to: { chapter: "ltr-unchanged" } },
|
|
30
|
+
]}
|
|
31
|
+
held={[
|
|
32
|
+
{ source: "PR title", quote: "fix(ui): preserve zoom controls order in RTL locales", to: { chapter: "rtl-order" } },
|
|
33
|
+
{ source: "PR diff", quote: "preserves LTR direction for zoom controls in RTL locales", to: { group: "tests" } },
|
|
34
|
+
{ source: "Implied by the removed RTL-only rules", quote: "(deleted :root[dir=\"rtl\"] rules)", to: { chapter: "ltr-unchanged" } },
|
|
35
|
+
]}
|
|
36
|
+
/>
|
|
37
|
+
</Overview>
|
|
38
|
+
),
|
|
39
|
+
|
|
40
|
+
chapters: [
|
|
41
|
+
{
|
|
42
|
+
id: "rtl-order",
|
|
43
|
+
title: "Button order in Hebrew and Arabic",
|
|
44
|
+
page: () => (
|
|
45
|
+
<Chapter
|
|
46
|
+
title="Button order in Hebrew and Arabic"
|
|
47
|
+
step="rtl-order"
|
|
48
|
+
expect={{ base: "Reads + 100% − from left to right in both locales.", head: "Reads − 100% + in both locales." }}
|
|
49
|
+
group="force-ltr"
|
|
50
|
+
evidence={<>
|
|
51
|
+
<Live />
|
|
52
|
+
<Compare title="Arabic" base="shots/base-ar-zoom.png" head="shots/head-ar-zoom.png" />
|
|
53
|
+
<Compare title="Dark theme, Hebrew" base="shots/base-he-dark.png" head="shots/head-he-dark.png" />
|
|
54
|
+
<Compare title="Hovering −" base="shots/base-he-hover.png" head="shots/head-he-hover.png" caption="The pressed highlight and the rounded corners follow the button." />
|
|
55
|
+
<Compare title="The whole page" base="shots/base-he-page.png" head="shots/head-he-page.png" caption="The zoom controls are outlined." />
|
|
56
|
+
</>}
|
|
57
|
+
>
|
|
58
|
+
Opens each instance in Hebrew and Arabic and sorts the three zoom buttons by their position on screen. The live apps open in Hebrew above the screenshots, so you can look at the controls yourself.
|
|
59
|
+
</Chapter>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "rtl-zen",
|
|
64
|
+
title: "Zen mode in Hebrew",
|
|
65
|
+
page: () => (
|
|
66
|
+
<Chapter
|
|
67
|
+
title="Zen mode in Hebrew"
|
|
68
|
+
step="rtl-zen"
|
|
69
|
+
expect={{ base: "The zoom level sits where − was, on the right (mirrored).", head: "The zoom level sits where − was, on the left." }}
|
|
70
|
+
group="zen"
|
|
71
|
+
>
|
|
72
|
+
In zen mode only the zoom level shows. It slides into the hidden − button's place. Presses Alt+Z in Hebrew and measures where the zoom level ends up compared with where − was. Try it yourself in the apps: Alt+Z, in Hebrew.
|
|
73
|
+
</Chapter>
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "ltr-unchanged",
|
|
78
|
+
title: "English is unchanged",
|
|
79
|
+
page: () => (
|
|
80
|
+
<Chapter
|
|
81
|
+
title="English is unchanged"
|
|
82
|
+
step="ltr-unchanged"
|
|
83
|
+
expect={{ base: "− 100% +", head: "− 100% +" }}
|
|
84
|
+
group="zen"
|
|
85
|
+
evidence={<>
|
|
86
|
+
<Compare title="English" base="shots/base-en-US-zoom.png" head="shots/head-en-US-zoom.png" mode="diff" caption="Nothing is marked: the deleted RTL rules did not touch the left-to-right layout." />
|
|
87
|
+
<Compare title="Tooltip, Hebrew" base="shots/base-he-tip.png" head="shots/head-he-tip.png" caption="Tooltips are drawn outside the zoom row, so they keep their own direction." />
|
|
88
|
+
</>}
|
|
89
|
+
>
|
|
90
|
+
Repeats the measurement in English on both instances, to show the deleted RTL rules did not touch the normal layout. Screenshots here compare the pixels, and the hover tooltip in Hebrew.
|
|
91
|
+
</Chapter>
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
|
|
96
|
+
groups: [
|
|
97
|
+
{ id: "force-ltr", title: "Force the zoom row to left-to-right", why: "The fix itself: one CSS rule and an inline style, so the flex row never reverses.", chapter: "rtl-order", file: ACTIONS, lines: [[880, 887]] },
|
|
98
|
+
{ id: "force-ltr-css", title: "The CSS rule", why: "Same direction set on the container class.", chapter: "rtl-order", file: SCSS, lines: [[8, 10]] },
|
|
99
|
+
{ id: "cleanup", title: "RTL-only mirroring removed", why: "These patched the mirrored row (flipping the rounded corners). With the row fixed, they would now do harm.", chapter: "rtl-order", file: SCSS, lines: [[55, 78]] },
|
|
100
|
+
{ id: "zen", title: "Zen mode shift", why: "The RTL-only opposite shift is deleted, because the left shift is now right in every locale.", chapter: "rtl-zen", file: STYLES, lines: [[738, 748]] },
|
|
101
|
+
{ id: "tests", title: "Test", why: "Renders Hebrew and checks the inline direction and the order of the buttons.", file: TEST, lines: [[718, 736]] },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
@@ -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
|
+
[]
|