@graphty/visual-review 0.2.0 → 0.2.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/README.md +448 -144
- package/capture/capture.mjs +7 -0
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +246 -42
- package/trusted/lib/accept.mjs +474 -116
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/config.mjs +4 -3
- package/trusted/lib/github.mjs +176 -29
- package/trusted/lib/serve.mjs +1237 -154
- package/trusted/lib/thumbs.mjs +149 -0
- package/trusted/page/index.html +20 -6
- package/trusted/page/passkey.js +94 -0
- package/trusted/page/review.css +924 -92
- package/trusted/page/review.js +3238 -991
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid thumbnails: a capture scaled down for a tile. Scaling one 2400 x 1800 capture takes about
|
|
3
|
+
* 90 ms of CPU, so it runs in a few child processes, never on the server's own thread, where a
|
|
4
|
+
* grid of 36 tiles held every other request for seconds. Processes, not worker threads: threads
|
|
5
|
+
* share the server's memory, which grew past 500 MB while scaling, and every git the server then
|
|
6
|
+
* started took 20 ms instead of 3 (the fork copies the parent's page tables). The same file is the
|
|
7
|
+
* child, started with the FLAG argument.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fork } from "node:child_process";
|
|
11
|
+
import { availableParallelism } from "node:os";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { PNG } from "pngjs";
|
|
15
|
+
|
|
16
|
+
// A grid tile is about 190 CSS pixels wide: 400 image pixels keep it sharp on a 2x screen, at
|
|
17
|
+
// about a thirtieth of a 2400 x 1800 capture's memory once decoded.
|
|
18
|
+
const THUMB_WIDTH = 400;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A PNG scaled down to `width` pixels across (never up), each output pixel the average of the
|
|
22
|
+
* source pixels under it. For display in the grid only: comparisons use the full images.
|
|
23
|
+
* @param {Buffer} bytes the PNG
|
|
24
|
+
* @param {number} width the widest the result may be
|
|
25
|
+
* @returns {Buffer} the smaller PNG
|
|
26
|
+
*/
|
|
27
|
+
export function thumbnail(bytes, width = THUMB_WIDTH) {
|
|
28
|
+
const src = PNG.sync.read(bytes);
|
|
29
|
+
const w = Math.min(width, src.width);
|
|
30
|
+
const h = Math.max(1, Math.round((src.height * w) / src.width));
|
|
31
|
+
const out = new PNG({ width: w, height: h });
|
|
32
|
+
for (let y = 0; y < h; y++) {
|
|
33
|
+
const [y0, y1] = [
|
|
34
|
+
Math.floor((y * src.height) / h),
|
|
35
|
+
Math.max(Math.floor(((y + 1) * src.height) / h), 1 + Math.floor((y * src.height) / h)),
|
|
36
|
+
];
|
|
37
|
+
for (let x = 0; x < w; x++) {
|
|
38
|
+
const [x0, x1] = [
|
|
39
|
+
Math.floor((x * src.width) / w),
|
|
40
|
+
Math.max(Math.floor(((x + 1) * src.width) / w), 1 + Math.floor((x * src.width) / w)),
|
|
41
|
+
];
|
|
42
|
+
const sum = [0, 0, 0, 0];
|
|
43
|
+
for (let sy = y0; sy < y1; sy++) {
|
|
44
|
+
for (let sx = x0; sx < x1; sx++) {
|
|
45
|
+
const i = (sy * src.width + sx) * 4;
|
|
46
|
+
for (let c = 0; c < 4; c++) {
|
|
47
|
+
sum[c] += src.data[i + c];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const n = (y1 - y0) * (x1 - x0);
|
|
52
|
+
const o = (y * w + x) * 4;
|
|
53
|
+
for (let c = 0; c < 4; c++) {
|
|
54
|
+
out.data[o + c] = Math.round(sum[c] / n);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return PNG.sync.write(out);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FLAG = "--visual-review-thumbnails";
|
|
62
|
+
if (process.argv[2] === FLAG) {
|
|
63
|
+
process.on("message", (/** @type {Uint8Array} */ bytes) => {
|
|
64
|
+
try {
|
|
65
|
+
process.send({ png: thumbnail(Buffer.from(bytes)) });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
process.send({ error: err.message });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Half the cores, at most four: the rest stay free for the server, git and the downloads.
|
|
73
|
+
const SIZE = Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2)));
|
|
74
|
+
const idle = [];
|
|
75
|
+
let made = 0;
|
|
76
|
+
/** @type {{ load: () => Promise<Buffer>, resolve: (png: Buffer) => void, reject: (err: Error) => void }[]} */
|
|
77
|
+
const queue = [];
|
|
78
|
+
|
|
79
|
+
function pump() {
|
|
80
|
+
while (queue.length > 0 && (idle.length > 0 || made < SIZE)) {
|
|
81
|
+
let worker = idle.pop();
|
|
82
|
+
if (!worker) {
|
|
83
|
+
// Buffers cross as buffers ("advanced"), not as JSON. Idle children never keep the
|
|
84
|
+
// server's process alive, and they end with it.
|
|
85
|
+
worker = fork(fileURLToPath(import.meta.url), [FLAG], { serialization: "advanced" });
|
|
86
|
+
worker.unref();
|
|
87
|
+
worker.channel?.unref();
|
|
88
|
+
made++;
|
|
89
|
+
// One that dies while idle is dropped; a send to it would throw.
|
|
90
|
+
const child = worker;
|
|
91
|
+
child.on("error", () => {});
|
|
92
|
+
child.on("exit", () => {
|
|
93
|
+
if (idle.includes(child)) {
|
|
94
|
+
idle.splice(idle.indexOf(child), 1);
|
|
95
|
+
made--;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
run(worker, queue.shift());
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function run(w, task) {
|
|
104
|
+
let bytes;
|
|
105
|
+
try {
|
|
106
|
+
bytes = await task.load();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
idle.push(w);
|
|
109
|
+
task.reject(err);
|
|
110
|
+
pump();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// A child that dies (out of memory, say) fails its task and is replaced by the next one.
|
|
114
|
+
const died = (code) => {
|
|
115
|
+
w.removeAllListeners("message");
|
|
116
|
+
made--;
|
|
117
|
+
task.reject(new Error(`the thumbnail process stopped (${code})`));
|
|
118
|
+
pump();
|
|
119
|
+
};
|
|
120
|
+
w.once("exit", died);
|
|
121
|
+
w.once("message", ({ png, error }) => {
|
|
122
|
+
w.removeListener("exit", died);
|
|
123
|
+
w.channel?.unref();
|
|
124
|
+
idle.push(w);
|
|
125
|
+
if (error) {
|
|
126
|
+
task.reject(new Error(error));
|
|
127
|
+
} else {
|
|
128
|
+
task.resolve(Buffer.from(png));
|
|
129
|
+
}
|
|
130
|
+
pump();
|
|
131
|
+
});
|
|
132
|
+
// Busy, a child keeps the server's process alive until it answers.
|
|
133
|
+
w.channel?.ref();
|
|
134
|
+
w.send(bytes);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `thumbnail` in a child process. The image is read only when a child takes it, so a long queue
|
|
139
|
+
* of thumbnails made in advance holds no images in memory.
|
|
140
|
+
* @param {() => Promise<Buffer>} load reads the PNG; what it throws, this rejects with
|
|
141
|
+
* @param {boolean} [urgent] a tile on screen now: ahead of the thumbnails made in advance
|
|
142
|
+
* @returns {Promise<Buffer>} the smaller PNG
|
|
143
|
+
*/
|
|
144
|
+
export function scaled(load, urgent = false) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
queue[urgent ? "unshift" : "push"]({ load, resolve, reject });
|
|
147
|
+
pump();
|
|
148
|
+
});
|
|
149
|
+
}
|
package/trusted/page/index.html
CHANGED
|
@@ -2,18 +2,32 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
6
|
<title>Visual review</title>
|
|
7
7
|
<link rel="stylesheet" href="review.css" />
|
|
8
8
|
<script type="module" src="review.js"></script>
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
|
-
<header>
|
|
12
|
-
<button type="button" id="home" class="link">Visual review</button>
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
<header id="top">
|
|
12
|
+
<h1><button type="button" id="home" class="link">Visual review</button></h1>
|
|
13
|
+
<label class="picker"
|
|
14
|
+
><span>Target</span
|
|
15
|
+
><select id="pick-target"></select
|
|
16
|
+
></label>
|
|
17
|
+
<label class="picker"
|
|
18
|
+
><span>Project</span
|
|
19
|
+
><select id="pick-project"></select
|
|
20
|
+
></label>
|
|
21
|
+
<span id="finish-slot"></span>
|
|
22
|
+
<button type="button" id="keys-button" aria-keyshortcuts="?">Keys <kbd aria-hidden="true">?</kbd></button>
|
|
23
|
+
<button type="button" id="copy-link">Copy link</button>
|
|
16
24
|
</header>
|
|
25
|
+
<div id="bar" role="region" aria-label="Tools"></div>
|
|
26
|
+
<div id="status-row" role="region" aria-label="Messages">
|
|
27
|
+
<span id="status" role="status"></span><span id="elapsed" aria-hidden="true"></span
|
|
28
|
+
><span id="alert" role="alert"></span
|
|
29
|
+
><button type="button" id="status-more" class="link" hidden>More</button>
|
|
30
|
+
</div>
|
|
17
31
|
<main id="app" tabindex="-1"><p>Loading...</p></main>
|
|
18
32
|
</body>
|
|
19
33
|
</html>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The review page's WebAuthn code: registering the owner's passkey, and approving a Finish with
|
|
3
|
+
* it (Face ID or Touch ID). The server builds every challenge and checks every answer; this file
|
|
4
|
+
* only hands bytes between it and the browser.
|
|
5
|
+
*
|
|
6
|
+
* Safari needs a user gesture for both calls, so no network request may sit between the press and
|
|
7
|
+
* the WebAuthn call: the challenge is always fetched before the button that uses it is shown, and
|
|
8
|
+
* each call below reaches `navigator.credentials` before its first `await`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Own helpers: Uint8Array.fromBase64 is missing from the Safari versions still in use.
|
|
12
|
+
const toB64u = (buffer) => {
|
|
13
|
+
let s = "";
|
|
14
|
+
for (const b of new Uint8Array(buffer)) {
|
|
15
|
+
s += String.fromCharCode(b);
|
|
16
|
+
}
|
|
17
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
};
|
|
19
|
+
const fromB64u = (s) => {
|
|
20
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
|
|
21
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
22
|
+
};
|
|
23
|
+
const credential = (id) => ({ type: "public-key", id: fromB64u(id) });
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fetches a registration challenge. Call it before showing the button that registers.
|
|
27
|
+
* @param {(path: string, body?: object) => Promise<any>} api the page's API call
|
|
28
|
+
* @returns {Promise<{ challenge: string, userId: string, rpId: string, known: string[] }>} what
|
|
29
|
+
* registerPasskey needs
|
|
30
|
+
*/
|
|
31
|
+
export const prepareRegistration = (api) => api("/api/passkey-challenge", {});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a passkey for this page's host and asks the server to register it, which opens a pull
|
|
35
|
+
* request adding it. Call it straight from the button's click.
|
|
36
|
+
* @param {(path: string, body?: object) => Promise<any>} api the page's API call
|
|
37
|
+
* @param {{ challenge: string, userId: string, rpId: string, known: string[] }} prepared from
|
|
38
|
+
* prepareRegistration
|
|
39
|
+
* @param {string | ((made: PublicKeyCredential) => string)} label what passkeys.json calls the key,
|
|
40
|
+
* or a function naming it from the credential made (a platform passkey or a security key)
|
|
41
|
+
* @returns {Promise<{ entry: object, branch: string, pullRequest: string, gated: boolean }>} the
|
|
42
|
+
* server's answer
|
|
43
|
+
*/
|
|
44
|
+
export async function registerPasskey(api, prepared, label) {
|
|
45
|
+
const created = navigator.credentials.create({
|
|
46
|
+
publicKey: {
|
|
47
|
+
rp: { id: prepared.rpId, name: "Visual review" },
|
|
48
|
+
user: { id: fromB64u(prepared.userId), name: "visual-review", displayName: "Visual review owner" },
|
|
49
|
+
challenge: fromB64u(prepared.challenge),
|
|
50
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
|
51
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
|
|
52
|
+
attestation: "none",
|
|
53
|
+
excludeCredentials: prepared.known.map(credential),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const made = await created;
|
|
57
|
+
const r = made.response;
|
|
58
|
+
return api("/api/register", {
|
|
59
|
+
challenge: prepared.challenge,
|
|
60
|
+
credentialId: toB64u(made.rawId),
|
|
61
|
+
publicKey: toB64u(r.getPublicKey()),
|
|
62
|
+
algorithm: r.getPublicKeyAlgorithm(),
|
|
63
|
+
authenticatorData: toB64u(r.getAuthenticatorData()),
|
|
64
|
+
clientDataJSON: toB64u(r.clientDataJSON),
|
|
65
|
+
label: typeof label === "function" ? label(made) : label,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Signs a prepared Finish's record with the passkey. Call it straight from the press that answers
|
|
71
|
+
* yes. A cancelled or refused prompt rejects with a NotAllowedError.
|
|
72
|
+
* @param {{ challenge: string, rpId: string, allowCredentials: string[] }} prepared from
|
|
73
|
+
* POST /api/finish-prepare
|
|
74
|
+
* @returns {Promise<{ credentialId: string, authenticatorData: string, clientDataJSON: string,
|
|
75
|
+
* signature: string }>} the approval POST /api/finish takes
|
|
76
|
+
*/
|
|
77
|
+
export async function approve(prepared) {
|
|
78
|
+
const got = await navigator.credentials.get({
|
|
79
|
+
publicKey: {
|
|
80
|
+
challenge: fromB64u(prepared.challenge),
|
|
81
|
+
rpId: prepared.rpId,
|
|
82
|
+
allowCredentials: prepared.allowCredentials.map(credential),
|
|
83
|
+
userVerification: "required",
|
|
84
|
+
timeout: 120000,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const r = got.response;
|
|
88
|
+
return {
|
|
89
|
+
credentialId: toB64u(got.rawId),
|
|
90
|
+
authenticatorData: toB64u(r.authenticatorData),
|
|
91
|
+
clientDataJSON: toB64u(r.clientDataJSON),
|
|
92
|
+
signature: toB64u(r.signature),
|
|
93
|
+
};
|
|
94
|
+
}
|