@goodfellow-cms/github 0.1.0 → 0.3.0
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 +2 -2
- package/dist/index.d.ts +114 -6
- package/dist/index.js +1352 -47
- package/dist/testing.d.ts +133 -1
- package/dist/testing.js +798 -15
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { ConflictError, GitApiError, SignInError, credentialStorage, encodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, compareReleases, credentialStorage, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/api.ts
|
|
3
3
|
/** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
|
|
4
4
|
async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
|
|
5
|
-
const
|
|
5
|
+
const send = async (refresh) => fetch(`${apiUrl}${path}`, {
|
|
6
6
|
method: init.method ?? "GET",
|
|
7
7
|
headers: {
|
|
8
8
|
accept: init.accept ?? "application/vnd.github+json",
|
|
9
|
-
authorization: `Bearer ${token}`,
|
|
9
|
+
authorization: `Bearer ${typeof token === "string" ? token : await token({ refresh })}`,
|
|
10
10
|
"x-github-api-version": "2022-11-28",
|
|
11
11
|
...init.body !== void 0 && { "content-type": "application/json" }
|
|
12
12
|
},
|
|
13
13
|
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
14
14
|
cache: "no-store"
|
|
15
15
|
});
|
|
16
|
+
let response = await send(false);
|
|
17
|
+
if (response.status === 401 && typeof token !== "string") response = await send(true);
|
|
16
18
|
if (response.status === 401) throw new SignInError("invalid", "GitHub didn't accept the sign-in. It may have expired.");
|
|
17
19
|
if (!response.ok && !init.allow?.includes(response.status)) {
|
|
18
20
|
const body = await response.json().catch(() => ({}));
|
|
@@ -28,7 +30,370 @@ function repoPath(repo) {
|
|
|
28
30
|
return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
29
31
|
}
|
|
30
32
|
//#endregion
|
|
33
|
+
//#region src/editors.ts
|
|
34
|
+
/** GitHub account names: letters, digits and single hyphens, up to 39 characters. */
|
|
35
|
+
const USERNAME = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
|
|
36
|
+
const INVITATION = "invitation:";
|
|
37
|
+
function roleOf(permissions) {
|
|
38
|
+
if (permissions?.admin) return "owner";
|
|
39
|
+
return permissions?.maintain || permissions?.push ? "editor" : "viewer";
|
|
40
|
+
}
|
|
41
|
+
function invitationRole(permission) {
|
|
42
|
+
if (permission === "admin") return "owner";
|
|
43
|
+
return permission === "write" || permission === "maintain" ? "editor" : "viewer";
|
|
44
|
+
}
|
|
45
|
+
function isNotAllowed(error) {
|
|
46
|
+
return error instanceof GitApiError && (error.status === 403 || error.status === 404);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The repository's collaborators. Anyone who can publish can list them, but
|
|
50
|
+
* inviting and removing people needs a token with GitHub's Administration
|
|
51
|
+
* permission, which signing in doesn't ask for: `manage()` gives the owner
|
|
52
|
+
* token when one is in use.
|
|
53
|
+
*/
|
|
54
|
+
function githubEditors(read, manage, repo, login) {
|
|
55
|
+
const path = `/repos/${repo}`;
|
|
56
|
+
function managing() {
|
|
57
|
+
const api = manage();
|
|
58
|
+
if (!api) throw new EditorsError("not-allowed", "Changing editors on GitHub needs an owner token.");
|
|
59
|
+
return api;
|
|
60
|
+
}
|
|
61
|
+
async function change(run) {
|
|
62
|
+
try {
|
|
63
|
+
await run(managing());
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function collaborators(api) {
|
|
70
|
+
const all = [];
|
|
71
|
+
for (let page = 1; page <= 10; page++) {
|
|
72
|
+
const found = await githubJson(api, `${path}/collaborators?per_page=100&page=${page}`);
|
|
73
|
+
all.push(...found);
|
|
74
|
+
if (found.length < 100) break;
|
|
75
|
+
}
|
|
76
|
+
return all;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
invitesByEmail: false,
|
|
80
|
+
async list() {
|
|
81
|
+
const api = manage() ?? read;
|
|
82
|
+
const people = await collaborators(api);
|
|
83
|
+
let invitations;
|
|
84
|
+
try {
|
|
85
|
+
invitations = await githubJson(api, `${path}/invitations?per_page=100`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!isNotAllowed(error)) throw error;
|
|
88
|
+
}
|
|
89
|
+
const editors = [...people.map((person) => ({
|
|
90
|
+
id: person.login,
|
|
91
|
+
login: person.login,
|
|
92
|
+
avatarUrl: person.avatar_url,
|
|
93
|
+
role: roleOf(person.permissions),
|
|
94
|
+
...person.login.toLowerCase() === login.toLowerCase() && { self: true }
|
|
95
|
+
})), ...(invitations ?? []).flatMap((invitation) => invitation.invitee ? [{
|
|
96
|
+
id: `${INVITATION}${invitation.id}`,
|
|
97
|
+
login: invitation.invitee.login,
|
|
98
|
+
avatarUrl: invitation.invitee.avatar_url,
|
|
99
|
+
role: invitationRole(invitation.permissions),
|
|
100
|
+
invited: true
|
|
101
|
+
}] : [])];
|
|
102
|
+
const owner = editors.some((editor) => editor.self && editor.role === "owner");
|
|
103
|
+
return {
|
|
104
|
+
editors,
|
|
105
|
+
manage: invitations ? "yes" : owner ? "token" : "no",
|
|
106
|
+
editorsCanPublish: true
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
async invite(who, role) {
|
|
110
|
+
const name = who.trim().replace(/^@/, "");
|
|
111
|
+
if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitHub username.");
|
|
112
|
+
const api = managing();
|
|
113
|
+
try {
|
|
114
|
+
await githubRequest(api, `${path}/collaborators/${encodeURIComponent(name)}`, {
|
|
115
|
+
method: "PUT",
|
|
116
|
+
body: { permission: role === "owner" ? "admin" : "push" }
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof GitApiError && error.status === 404) {
|
|
120
|
+
if ((await githubRequest(api, `/users/${encodeURIComponent(name)}`, { allow: [404] })).status === 404) throw new EditorsError("not-found", `There's no GitHub account named ${name}.`);
|
|
121
|
+
}
|
|
122
|
+
if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
|
|
123
|
+
if (error instanceof GitApiError && error.status === 422) throw new EditorsError("invalid", `GitHub didn't accept the invitation: ${error.message}`);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async setRole(editor, role) {
|
|
128
|
+
await change(async (api) => {
|
|
129
|
+
if (editor.id.startsWith(INVITATION)) await githubRequest(api, `${path}/invitations/${editor.id.slice(11)}`, {
|
|
130
|
+
method: "PATCH",
|
|
131
|
+
body: { permissions: role === "owner" ? "admin" : "write" }
|
|
132
|
+
});
|
|
133
|
+
else await githubRequest(api, `${path}/collaborators/${encodeURIComponent(editor.id)}`, {
|
|
134
|
+
method: "PUT",
|
|
135
|
+
body: { permission: role === "owner" ? "admin" : "push" }
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
async remove(editor) {
|
|
140
|
+
await change((api) => githubRequest(api, editor.id.startsWith(INVITATION) ? `${path}/invitations/${editor.id.slice(11)}` : `${path}/collaborators/${encodeURIComponent(editor.id)}`, { method: "DELETE" }));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The owner token: GitHub's Administration and Pages permissions, for this tab
|
|
146
|
+
* only. With the sign-in worker, owners can sign in with its owners' app
|
|
147
|
+
* instead (`redirect`), and `useSignIn()` gives the token that comes back.
|
|
148
|
+
*/
|
|
149
|
+
function githubOwnerAccess(base, repo, tokenLink, redirect) {
|
|
150
|
+
let owner;
|
|
151
|
+
let endSignIn;
|
|
152
|
+
let problem;
|
|
153
|
+
return {
|
|
154
|
+
access: {
|
|
155
|
+
tokenLink,
|
|
156
|
+
get active() {
|
|
157
|
+
return owner !== void 0;
|
|
158
|
+
},
|
|
159
|
+
get problem() {
|
|
160
|
+
return problem;
|
|
161
|
+
},
|
|
162
|
+
...redirect && { async startRedirect(options) {
|
|
163
|
+
problem = void 0;
|
|
164
|
+
await redirect(options);
|
|
165
|
+
} },
|
|
166
|
+
async applyToken(token) {
|
|
167
|
+
const api = {
|
|
168
|
+
...base,
|
|
169
|
+
token: token.trim()
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
if (!(await githubJson(api, `/repos/${repo}`)).permissions?.admin) throw new EditorsError("not-allowed", "This token's account isn't an owner of the site.");
|
|
173
|
+
await githubJson(api, `/repos/${repo}/invitations?per_page=1`);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof EditorsError) throw error;
|
|
176
|
+
if (error instanceof SignInError || isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
owner = api;
|
|
180
|
+
problem = void 0;
|
|
181
|
+
},
|
|
182
|
+
forget() {
|
|
183
|
+
owner = void 0;
|
|
184
|
+
endSignIn?.();
|
|
185
|
+
endSignIn = void 0;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
api: () => owner,
|
|
189
|
+
/** Uses an owner's sign-in through the worker. `end` runs when it's forgotten. */
|
|
190
|
+
useSignIn(token, end) {
|
|
191
|
+
owner = {
|
|
192
|
+
...base,
|
|
193
|
+
token
|
|
194
|
+
};
|
|
195
|
+
endSignIn = end;
|
|
196
|
+
problem = void 0;
|
|
197
|
+
},
|
|
198
|
+
/** Remembers why an owner's sign-in didn't work, for the screen that started it to show. */
|
|
199
|
+
failed(error) {
|
|
200
|
+
problem = error;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/pages.ts
|
|
206
|
+
/** GitHub Pages' addresses for an apex domain, from GitHub's documentation. */
|
|
207
|
+
const PAGES_IPV4 = [
|
|
208
|
+
"185.199.108.153",
|
|
209
|
+
"185.199.109.153",
|
|
210
|
+
"185.199.110.153",
|
|
211
|
+
"185.199.111.153"
|
|
212
|
+
];
|
|
213
|
+
const PAGES_IPV6 = [
|
|
214
|
+
"2606:50c0:8000::153",
|
|
215
|
+
"2606:50c0:8001::153",
|
|
216
|
+
"2606:50c0:8002::153",
|
|
217
|
+
"2606:50c0:8003::153"
|
|
218
|
+
];
|
|
219
|
+
/** Certificate states in which GitHub serves the domain over HTTPS. */
|
|
220
|
+
const READY = /* @__PURE__ */ new Set([
|
|
221
|
+
"approved",
|
|
222
|
+
"issued",
|
|
223
|
+
"uploaded"
|
|
224
|
+
]);
|
|
225
|
+
const FAILED = /* @__PURE__ */ new Set([
|
|
226
|
+
"errored",
|
|
227
|
+
"bad_authz",
|
|
228
|
+
"authorization_revoked"
|
|
229
|
+
]);
|
|
230
|
+
/** The records GitHub Pages needs: its addresses for an apex domain, or an alias of the owner's github.io. */
|
|
231
|
+
function githubPagesRecords(repo, domain, { apex, www }) {
|
|
232
|
+
const target = `${(repo.split("/")[0] ?? "").toLowerCase()}.github.io`;
|
|
233
|
+
if (!apex) return [{
|
|
234
|
+
type: "CNAME",
|
|
235
|
+
name: domain,
|
|
236
|
+
value: target,
|
|
237
|
+
purpose: "site"
|
|
238
|
+
}];
|
|
239
|
+
return [
|
|
240
|
+
...PAGES_IPV4.map((value) => ({
|
|
241
|
+
type: "A",
|
|
242
|
+
name: domain,
|
|
243
|
+
value,
|
|
244
|
+
purpose: "site"
|
|
245
|
+
})),
|
|
246
|
+
...PAGES_IPV6.map((value) => ({
|
|
247
|
+
type: "AAAA",
|
|
248
|
+
name: domain,
|
|
249
|
+
value,
|
|
250
|
+
purpose: "site",
|
|
251
|
+
optional: true
|
|
252
|
+
})),
|
|
253
|
+
...www ? [{
|
|
254
|
+
type: "CNAME",
|
|
255
|
+
name: `www.${domain}`,
|
|
256
|
+
value: target,
|
|
257
|
+
purpose: "www",
|
|
258
|
+
optional: true
|
|
259
|
+
}] : []
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* GitHub Pages, for a site's repository: where it's published, and its custom
|
|
264
|
+
* domain. `api` gives the owner token when one is in use (see `ownerAccess`).
|
|
265
|
+
*/
|
|
266
|
+
function githubPages(api, repo) {
|
|
267
|
+
const path = `/repos/${repo}/pages`;
|
|
268
|
+
async function pages() {
|
|
269
|
+
const response = await githubRequest(api(), path, { allow: [404] });
|
|
270
|
+
return response.status === 404 ? void 0 : await response.json();
|
|
271
|
+
}
|
|
272
|
+
async function update(body) {
|
|
273
|
+
try {
|
|
274
|
+
await githubRequest(api(), path, {
|
|
275
|
+
method: "PUT",
|
|
276
|
+
body
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new DomainError("not-allowed", "This sign-in can't change the site's GitHub Pages settings.");
|
|
280
|
+
if (error instanceof GitApiError && (error.status === 400 || error.status === 422)) {
|
|
281
|
+
if (/taken|already/i.test(error.message)) throw new DomainError("taken", "Another GitHub Pages site already uses this domain.");
|
|
282
|
+
if ("https_enforced" in body) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
|
|
283
|
+
throw new DomainError("invalid", "GitHub Pages doesn't accept this domain.");
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
async site() {
|
|
290
|
+
const found = await pages();
|
|
291
|
+
return found && {
|
|
292
|
+
url: found.html_url,
|
|
293
|
+
domain: found.cname ?? void 0
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
async connect(domain, options) {
|
|
297
|
+
await update({ cname: domain });
|
|
298
|
+
return githubPagesRecords(repo, domain, options);
|
|
299
|
+
},
|
|
300
|
+
async records(domain, options) {
|
|
301
|
+
return githubPagesRecords(repo, domain, options);
|
|
302
|
+
},
|
|
303
|
+
async status(domain) {
|
|
304
|
+
const found = await githubJson(api(), path);
|
|
305
|
+
const state = found.https_certificate?.state ?? "";
|
|
306
|
+
return {
|
|
307
|
+
verified: true,
|
|
308
|
+
certificate: READY.has(state) ? "ready" : FAILED.has(state) ? "failed" : "pending",
|
|
309
|
+
httpsOnly: found.https_enforced === true,
|
|
310
|
+
primary: found.cname === domain
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
async secure() {
|
|
314
|
+
await update({ https_enforced: true });
|
|
315
|
+
},
|
|
316
|
+
async disconnect() {
|
|
317
|
+
await update({ cname: null });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/updates.ts
|
|
323
|
+
/** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
|
|
324
|
+
const WORKFLOW = "deploy.yml";
|
|
325
|
+
const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
|
|
326
|
+
/**
|
|
327
|
+
* The site's nightly update, which runs in its GitHub Pages workflow. Running
|
|
328
|
+
* it now dispatches the workflow, which needs GitHub's Actions permission
|
|
329
|
+
* with write access: `manage()` gives the owner token when one is in use.
|
|
330
|
+
*/
|
|
331
|
+
function githubUpdates(read, manage, repo, branch, files) {
|
|
332
|
+
const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
|
|
333
|
+
return {
|
|
334
|
+
async setup() {
|
|
335
|
+
return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
|
|
336
|
+
},
|
|
337
|
+
async lastRun() {
|
|
338
|
+
const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
|
|
339
|
+
if (!response.ok) return void 0;
|
|
340
|
+
const { workflow_runs: runs = [] } = await response.json();
|
|
341
|
+
for (const run of runs.filter((candidate) => ["schedule", "workflow_dispatch"].includes(candidate.event))) {
|
|
342
|
+
const base = {
|
|
343
|
+
date: run.created_at,
|
|
344
|
+
detailsUrl: run.html_url
|
|
345
|
+
};
|
|
346
|
+
if (run.status !== "completed") return {
|
|
347
|
+
state: "running",
|
|
348
|
+
...base
|
|
349
|
+
};
|
|
350
|
+
const { jobs = [] } = await githubJson(read, `/repos/${repo}/actions/runs/${run.id}/jobs`);
|
|
351
|
+
const job = jobs.find((candidate) => candidate.name === "update");
|
|
352
|
+
if (!job || job.conclusion === "skipped") continue;
|
|
353
|
+
if (job.conclusion !== "success") return {
|
|
354
|
+
state: "failed",
|
|
355
|
+
...base
|
|
356
|
+
};
|
|
357
|
+
const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
|
|
358
|
+
const now = siteVersion(await files.read("package.json") ?? "");
|
|
359
|
+
if (!before || !now || before === now) return {
|
|
360
|
+
state: "up-to-date",
|
|
361
|
+
...base
|
|
362
|
+
};
|
|
363
|
+
const rollback = compareReleases(now, before) < 0;
|
|
364
|
+
return {
|
|
365
|
+
state: "updated",
|
|
366
|
+
...base,
|
|
367
|
+
result: {
|
|
368
|
+
state: "updated",
|
|
369
|
+
from: before,
|
|
370
|
+
to: now,
|
|
371
|
+
...rollback && { rollback }
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
async start(target) {
|
|
377
|
+
try {
|
|
378
|
+
await githubRequest(manage() ?? read, `${path}/dispatches`, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
body: {
|
|
381
|
+
ref: branch,
|
|
382
|
+
inputs: { update: target }
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new UpdatesError("not-allowed", "Running the update needs a token that can run the site's workflows.");
|
|
387
|
+
if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
31
394
|
//#region src/backend.ts
|
|
395
|
+
/** How many files one GraphQL request reads. */
|
|
396
|
+
const GRAPHQL_BATCH = 100;
|
|
32
397
|
const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
33
398
|
createCommitOnBranch(input: $input) { commit { oid } }
|
|
34
399
|
}`;
|
|
@@ -37,21 +402,99 @@ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
|
37
402
|
* returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
|
|
38
403
|
* which fails if the branch has moved, so nothing is ever overwritten.
|
|
39
404
|
*/
|
|
40
|
-
var GitHubBackend = class {
|
|
405
|
+
var GitHubBackend = class GitHubBackend {
|
|
41
406
|
user;
|
|
407
|
+
pages;
|
|
408
|
+
editors;
|
|
409
|
+
ownerAccess;
|
|
410
|
+
updates;
|
|
411
|
+
drafts;
|
|
412
|
+
options;
|
|
42
413
|
api;
|
|
43
414
|
repo;
|
|
44
415
|
branch;
|
|
45
416
|
onSignOut;
|
|
417
|
+
author;
|
|
418
|
+
owner;
|
|
46
419
|
pinned;
|
|
47
420
|
trees = /* @__PURE__ */ new Map();
|
|
421
|
+
/** Each file's size in bytes at a commit, which GitHub lists with its tree. */
|
|
422
|
+
treeSizes = /* @__PURE__ */ new Map();
|
|
48
423
|
blobs = /* @__PURE__ */ new Map();
|
|
49
424
|
constructor(options) {
|
|
425
|
+
this.options = options;
|
|
50
426
|
this.api = options.api;
|
|
51
427
|
this.repo = repoPath(options.repo);
|
|
52
428
|
this.branch = options.branch;
|
|
53
429
|
this.user = options.user;
|
|
54
430
|
this.onSignOut = options.onSignOut;
|
|
431
|
+
this.author = options.author;
|
|
432
|
+
const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
|
|
433
|
+
this.owner = owner;
|
|
434
|
+
this.ownerAccess = owner.access;
|
|
435
|
+
this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
|
|
436
|
+
this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
|
|
437
|
+
this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
|
|
438
|
+
this.drafts = branchDrafts(this, this.draftHost());
|
|
439
|
+
}
|
|
440
|
+
/** Drafts on branches of the repository, with pull requests for review. */
|
|
441
|
+
draftHost() {
|
|
442
|
+
const base = `/repos/${this.repo}`;
|
|
443
|
+
const ref = (branch) => `${base}/git/refs/heads/${encodeURIComponent(branch)}`;
|
|
444
|
+
return {
|
|
445
|
+
liveBranch: this.branch,
|
|
446
|
+
branchStore: (branch) => new GitHubBackend({
|
|
447
|
+
...this.options,
|
|
448
|
+
branch
|
|
449
|
+
}),
|
|
450
|
+
createBranch: async (branch, revision) => {
|
|
451
|
+
if ((await githubRequest(this.api, `${base}/git/refs`, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
body: {
|
|
454
|
+
ref: `refs/heads/${branch}`,
|
|
455
|
+
sha: revision
|
|
456
|
+
},
|
|
457
|
+
allow: [422]
|
|
458
|
+
})).status === 422) throw new DraftError("exists", `There's already a draft called ${branch}.`);
|
|
459
|
+
},
|
|
460
|
+
deleteBranch: async (branch) => {
|
|
461
|
+
await githubRequest(this.api, ref(branch), {
|
|
462
|
+
method: "DELETE",
|
|
463
|
+
allow: [404, 422]
|
|
464
|
+
});
|
|
465
|
+
},
|
|
466
|
+
mergeBase: async (branch) => {
|
|
467
|
+
const response = await githubRequest(this.api, `${base}/compare/${encodeURIComponent(this.branch)}...${encodeURIComponent(branch)}`, { allow: [404] });
|
|
468
|
+
if (response.status === 404) throw new DraftError("not-found", `There's no draft ${branch}.`);
|
|
469
|
+
return (await response.json()).merge_base_commit.sha;
|
|
470
|
+
},
|
|
471
|
+
openReview: async ({ branch, title, description }) => {
|
|
472
|
+
const response = await githubRequest(this.api, `${base}/pulls`, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
body: {
|
|
475
|
+
title,
|
|
476
|
+
head: branch,
|
|
477
|
+
base: this.branch,
|
|
478
|
+
body: description
|
|
479
|
+
},
|
|
480
|
+
allow: [
|
|
481
|
+
403,
|
|
482
|
+
404,
|
|
483
|
+
422
|
|
484
|
+
]
|
|
485
|
+
});
|
|
486
|
+
if (response.ok) return { url: (await response.json()).html_url };
|
|
487
|
+
if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
|
|
488
|
+
const owner = decodeURIComponent(this.repo).split("/")[0];
|
|
489
|
+
const query = new URLSearchParams({
|
|
490
|
+
head: `${owner}:${branch}`,
|
|
491
|
+
state: "open"
|
|
492
|
+
});
|
|
493
|
+
const [open] = await githubJson(this.api, `${base}/pulls?${query}`);
|
|
494
|
+
if (!open) throw new GitApiError(422, "GitHub couldn't open the pull request.");
|
|
495
|
+
return { url: open.html_url };
|
|
496
|
+
}
|
|
497
|
+
};
|
|
55
498
|
}
|
|
56
499
|
async revision() {
|
|
57
500
|
const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
|
|
@@ -64,7 +507,9 @@ var GitHubBackend = class {
|
|
|
64
507
|
if (!tree) {
|
|
65
508
|
tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
|
|
66
509
|
if (response.truncated) throw new GitApiError(413, "The repository has too many files for GitHub to list at once.");
|
|
67
|
-
|
|
510
|
+
const blobs = response.tree.filter((entry) => entry.type === "blob");
|
|
511
|
+
this.treeSizes.set(sha, new Map(blobs.flatMap((entry) => entry.size === void 0 ? [] : [[entry.path, entry.size]])));
|
|
512
|
+
return new Map(blobs.map((entry) => [entry.path, entry.sha]));
|
|
68
513
|
});
|
|
69
514
|
tree.catch(() => this.trees.delete(sha));
|
|
70
515
|
this.trees.set(sha, tree);
|
|
@@ -75,8 +520,11 @@ var GitHubBackend = class {
|
|
|
75
520
|
return this.tree(this.pinned ?? await this.revision());
|
|
76
521
|
}
|
|
77
522
|
async read(path) {
|
|
78
|
-
|
|
79
|
-
|
|
523
|
+
return this.readFrom(await this.currentTree(), path);
|
|
524
|
+
}
|
|
525
|
+
readFrom(tree, path) {
|
|
526
|
+
const sha = tree.get(path);
|
|
527
|
+
if (!sha) return Promise.resolve(void 0);
|
|
80
528
|
let blob = this.blobs.get(sha);
|
|
81
529
|
if (!blob) {
|
|
82
530
|
blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
|
|
@@ -85,6 +533,40 @@ var GitHubBackend = class {
|
|
|
85
533
|
}
|
|
86
534
|
return blob;
|
|
87
535
|
}
|
|
536
|
+
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
537
|
+
async readFiles(paths) {
|
|
538
|
+
const tree = await this.currentTree();
|
|
539
|
+
const wanted = paths.flatMap((path) => {
|
|
540
|
+
const sha = tree.get(path);
|
|
541
|
+
return sha ? [[path, sha]] : [];
|
|
542
|
+
});
|
|
543
|
+
const fetch = [...new Set(wanted.map(([, sha]) => sha).filter((sha) => !this.blobs.has(sha)))];
|
|
544
|
+
const [owner, name] = decodeURIComponent(this.repo).split("/");
|
|
545
|
+
for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
|
|
546
|
+
const batch = fetch.slice(start, start + GRAPHQL_BATCH);
|
|
547
|
+
const fields = batch.map((sha, index) => `f${index}: object(oid: "${sha}") { ... on Blob { text isBinary } }`);
|
|
548
|
+
const response = await githubJson(this.api, "/graphql", {
|
|
549
|
+
method: "POST",
|
|
550
|
+
body: {
|
|
551
|
+
query: `query ($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields.join(" ")} } }`,
|
|
552
|
+
variables: {
|
|
553
|
+
owner,
|
|
554
|
+
name
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
batch.forEach((sha, index) => {
|
|
559
|
+
const blob = response.data?.repository?.[`f${index}`];
|
|
560
|
+
if (blob && !blob.isBinary && typeof blob.text === "string") this.blobs.set(sha, Promise.resolve(blob.text));
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const result = /* @__PURE__ */ new Map();
|
|
564
|
+
for (const [path] of wanted) {
|
|
565
|
+
const text = await this.readFrom(tree, path);
|
|
566
|
+
if (text !== void 0) result.set(path, text);
|
|
567
|
+
}
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
88
570
|
async readBytes(path) {
|
|
89
571
|
const sha = (await this.currentTree()).get(path);
|
|
90
572
|
if (!sha) return void 0;
|
|
@@ -95,7 +577,100 @@ var GitHubBackend = class {
|
|
|
95
577
|
const prefix = `${dir}/`;
|
|
96
578
|
return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
97
579
|
}
|
|
98
|
-
async
|
|
580
|
+
async fileSizes(dir) {
|
|
581
|
+
const prefix = `${dir}/`;
|
|
582
|
+
await this.currentTree();
|
|
583
|
+
const sizes = this.treeSizes.get(this.pinned ?? "") ?? /* @__PURE__ */ new Map();
|
|
584
|
+
return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
|
|
585
|
+
}
|
|
586
|
+
/** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
|
|
587
|
+
useOwnerSignIn(token, end) {
|
|
588
|
+
this.owner.useSignIn(token, end);
|
|
589
|
+
}
|
|
590
|
+
/** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
|
|
591
|
+
ownerSignInFailed(error) {
|
|
592
|
+
this.owner.failed(error);
|
|
593
|
+
}
|
|
594
|
+
async write(changes, options) {
|
|
595
|
+
return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Saves with Git's data API, naming the editor as the commit's author:
|
|
599
|
+
* GitHub refuses to move the branch unless the commit follows on from it, so
|
|
600
|
+
* nothing is overwritten.
|
|
601
|
+
*/
|
|
602
|
+
async writeCommit(changes, { message, expectedRevision }, author) {
|
|
603
|
+
const existing = await this.tree(expectedRevision);
|
|
604
|
+
const base = `/repos/${this.repo}`;
|
|
605
|
+
const entries = [];
|
|
606
|
+
try {
|
|
607
|
+
for (const change of changes) if ("delete" in change) {
|
|
608
|
+
if (existing.has(change.path)) entries.push({
|
|
609
|
+
path: change.path,
|
|
610
|
+
mode: "100644",
|
|
611
|
+
type: "blob",
|
|
612
|
+
sha: null
|
|
613
|
+
});
|
|
614
|
+
} else if ("bytes" in change) {
|
|
615
|
+
const blob = await githubJson(this.api, `${base}/git/blobs`, {
|
|
616
|
+
method: "POST",
|
|
617
|
+
body: {
|
|
618
|
+
content: encodeBase64Bytes(change.bytes),
|
|
619
|
+
encoding: "base64"
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
entries.push({
|
|
623
|
+
path: change.path,
|
|
624
|
+
mode: "100644",
|
|
625
|
+
type: "blob",
|
|
626
|
+
sha: blob.sha
|
|
627
|
+
});
|
|
628
|
+
} else entries.push({
|
|
629
|
+
path: change.path,
|
|
630
|
+
mode: "100644",
|
|
631
|
+
type: "blob",
|
|
632
|
+
content: change.content
|
|
633
|
+
});
|
|
634
|
+
if (entries.length === 0) return { revision: expectedRevision };
|
|
635
|
+
const parent = await githubJson(this.api, `${base}/git/commits/${expectedRevision}`);
|
|
636
|
+
const tree = await githubJson(this.api, `${base}/git/trees`, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
body: {
|
|
639
|
+
base_tree: parent.tree.sha,
|
|
640
|
+
tree: entries
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
const commit = await githubJson(this.api, `${base}/git/commits`, {
|
|
644
|
+
method: "POST",
|
|
645
|
+
body: {
|
|
646
|
+
message,
|
|
647
|
+
tree: tree.sha,
|
|
648
|
+
parents: [expectedRevision],
|
|
649
|
+
author: {
|
|
650
|
+
...author,
|
|
651
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
if ((await githubRequest(this.api, `${base}/git/refs/heads/${encodeURIComponent(this.branch)}`, {
|
|
656
|
+
method: "PATCH",
|
|
657
|
+
body: {
|
|
658
|
+
sha: commit.sha,
|
|
659
|
+
force: false
|
|
660
|
+
},
|
|
661
|
+
allow: [422]
|
|
662
|
+
})).status === 422) {
|
|
663
|
+
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
664
|
+
throw new GitApiError(422, "GitHub couldn't save the changes.");
|
|
665
|
+
}
|
|
666
|
+
this.pinned = commit.sha;
|
|
667
|
+
return { revision: commit.sha };
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SignInError("cant-publish", "This sign-in can't publish to the site.");
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async writeGraphQL(changes, { message, expectedRevision }) {
|
|
99
674
|
const existing = await this.tree(expectedRevision);
|
|
100
675
|
const additions = changes.flatMap((change) => "delete" in change ? [] : [{
|
|
101
676
|
path: change.path,
|
|
@@ -131,68 +706,655 @@ var GitHubBackend = class {
|
|
|
131
706
|
if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
|
|
132
707
|
throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
|
|
133
708
|
}
|
|
709
|
+
async history(path, { page = 1, perPage = 20 } = {}) {
|
|
710
|
+
const query = new URLSearchParams({
|
|
711
|
+
sha: this.pinned ?? await this.revision(),
|
|
712
|
+
path,
|
|
713
|
+
per_page: String(perPage),
|
|
714
|
+
page: String(page)
|
|
715
|
+
});
|
|
716
|
+
return (await githubJson(this.api, `/repos/${this.repo}/commits?${query}`)).map((commit) => ({
|
|
717
|
+
revision: commit.sha,
|
|
718
|
+
date: commit.commit.author?.date ?? "",
|
|
719
|
+
author: commit.commit.author?.name || commit.author?.login || void 0,
|
|
720
|
+
message: commit.commit.message.split("\n")[0] ?? ""
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
async readAt(path, revision) {
|
|
724
|
+
return this.readFrom(await this.tree(revision), path);
|
|
725
|
+
}
|
|
134
726
|
async changedPaths(from, to) {
|
|
135
727
|
const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
|
|
136
728
|
return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
|
|
137
729
|
}
|
|
138
730
|
/**
|
|
139
|
-
* Reads the deployment GitHub Pages or Vercel created for a commit
|
|
140
|
-
*
|
|
731
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit, and for
|
|
732
|
+
* GitHub Actions, the workflow run that builds it: a build that fails never
|
|
733
|
+
* creates a deployment. Before either exists, the site counts as building
|
|
734
|
+
* if it has ever been deployed.
|
|
141
735
|
*/
|
|
142
736
|
async deployStatus(revision) {
|
|
143
737
|
try {
|
|
144
738
|
const latest = (await githubJson(this.api, `/repos/${this.repo}/deployments?sha=${revision}&per_page=5`))[0];
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
739
|
+
if (latest) {
|
|
740
|
+
const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
|
|
741
|
+
const detailsUrl = status?.log_url || status?.target_url || void 0;
|
|
742
|
+
switch (status?.state) {
|
|
743
|
+
case "success":
|
|
744
|
+
case "inactive": return {
|
|
745
|
+
state: "live",
|
|
746
|
+
detailsUrl
|
|
747
|
+
};
|
|
748
|
+
case "failure":
|
|
749
|
+
case "error": {
|
|
750
|
+
const run = await this.workflowRun(revision);
|
|
751
|
+
return {
|
|
752
|
+
state: "failed",
|
|
753
|
+
detailsUrl,
|
|
754
|
+
problem: run ? await this.runProblem(run) : { step: "deploy" }
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
default: return {
|
|
758
|
+
state: "building",
|
|
759
|
+
detailsUrl
|
|
760
|
+
};
|
|
761
|
+
}
|
|
163
762
|
}
|
|
763
|
+
const run = await this.workflowRun(revision);
|
|
764
|
+
if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
|
|
765
|
+
state: "failed",
|
|
766
|
+
detailsUrl: run.html_url,
|
|
767
|
+
problem: await this.runProblem(run)
|
|
768
|
+
};
|
|
769
|
+
if (run) return {
|
|
770
|
+
state: "building",
|
|
771
|
+
detailsUrl: run.html_url
|
|
772
|
+
};
|
|
773
|
+
return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
|
|
164
774
|
} catch (error) {
|
|
165
775
|
if (error instanceof GitApiError) return { state: "unknown" };
|
|
166
776
|
throw error;
|
|
167
777
|
}
|
|
168
778
|
}
|
|
779
|
+
/** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
|
|
780
|
+
async workflowRun(revision) {
|
|
781
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
|
|
782
|
+
if (!response.ok) return void 0;
|
|
783
|
+
const { workflow_runs: runs = [] } = await response.json();
|
|
784
|
+
return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
|
|
785
|
+
}
|
|
786
|
+
/** Which step of a failed run failed. */
|
|
787
|
+
async runProblem(run) {
|
|
788
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs/${run.id}/jobs?per_page=50`, { allow: [403, 404] });
|
|
789
|
+
const failed = (response.ok ? (await response.json()).jobs ?? [] : []).find((job) => job.conclusion === "failure");
|
|
790
|
+
if (!failed) return {
|
|
791
|
+
step: run.conclusion === "startup_failure" ? "not-started" : "host",
|
|
792
|
+
detail: run.conclusion ?? void 0
|
|
793
|
+
};
|
|
794
|
+
const step = failed.steps?.find((candidate) => candidate.conclusion === "failure");
|
|
795
|
+
if (!step) return {
|
|
796
|
+
step: "not-started",
|
|
797
|
+
detail: failed.name
|
|
798
|
+
};
|
|
799
|
+
return {
|
|
800
|
+
step: buildStepFor(step.name) ?? (/deploy/i.test(failed.name) ? "deploy" : "build"),
|
|
801
|
+
detail: `${failed.name}: ${step.name}`
|
|
802
|
+
};
|
|
803
|
+
}
|
|
169
804
|
signOut() {
|
|
805
|
+
this.ownerAccess.forget();
|
|
170
806
|
this.onSignOut();
|
|
171
807
|
}
|
|
172
808
|
};
|
|
173
809
|
//#endregion
|
|
810
|
+
//#region src/sign-in.ts
|
|
811
|
+
/**
|
|
812
|
+
* Talking to the sign-in worker (`@goodfellow-cms/worker`), which signs
|
|
813
|
+
* editors in with GitHub and gives the admin panel a token for the site's
|
|
814
|
+
* repository, lasting an hour. Requests are sent as plain text, so browsers
|
|
815
|
+
* send them straight away without asking the worker first.
|
|
816
|
+
*/
|
|
817
|
+
/** The sign-in worker Goodfellow runs for every site. Sites can run their own and name it in `github({ signIn })`. */
|
|
818
|
+
const GOODFELLOW_SIGN_IN = "https://sign-in.goodfellow-cms.workers.dev";
|
|
819
|
+
/** The worker's problems, as the admin panel's sign-in problems. */
|
|
820
|
+
const PROBLEMS = {
|
|
821
|
+
invalid: "invalid",
|
|
822
|
+
"no-access": "no-access",
|
|
823
|
+
"cant-publish": "cant-publish",
|
|
824
|
+
cancelled: "redirect-failed",
|
|
825
|
+
"not-installed": "not-installed",
|
|
826
|
+
"no-address": "no-address",
|
|
827
|
+
"wrong-address": "wrong-address",
|
|
828
|
+
"local-address": "local-address",
|
|
829
|
+
"not-owner": "not-owner"
|
|
830
|
+
};
|
|
831
|
+
/** The worker's answer as a `SignInError`. */
|
|
832
|
+
function signInProblem(answer) {
|
|
833
|
+
const problem = PROBLEMS[String(answer.problem)] ?? "unavailable";
|
|
834
|
+
const link = typeof answer.installUrl === "string" && /^https:\/\//.test(answer.installUrl) ? answer.installUrl : void 0;
|
|
835
|
+
return new SignInError(problem, `The sign-in service said: ${String(answer.problem ?? "no answer")}.`, link);
|
|
836
|
+
}
|
|
837
|
+
/** Addresses on the computer itself, which the worker never gives tokens to. */
|
|
838
|
+
function isLocalAddress(href) {
|
|
839
|
+
let host;
|
|
840
|
+
try {
|
|
841
|
+
host = new URL(href).hostname.toLowerCase();
|
|
842
|
+
} catch {
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
|
|
846
|
+
}
|
|
847
|
+
function base64Url(bytes) {
|
|
848
|
+
let binary = "";
|
|
849
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
850
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
851
|
+
}
|
|
852
|
+
/** A secret that never leaves this tab, and its hash, which the worker checks it against (PKCE's S256). */
|
|
853
|
+
async function newVerifier() {
|
|
854
|
+
const verifier = base64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
855
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
856
|
+
return {
|
|
857
|
+
verifier,
|
|
858
|
+
challenge: base64Url(new Uint8Array(digest))
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function browserLocation() {
|
|
862
|
+
if (typeof window === "undefined") return void 0;
|
|
863
|
+
return {
|
|
864
|
+
href: () => window.location.href,
|
|
865
|
+
assign: (url) => window.location.assign(url),
|
|
866
|
+
replace: (url) => window.history.replaceState(window.history.state, "", url)
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
/** Query parameters the worker comes back with. */
|
|
870
|
+
const GRANT_PARAM = "goodfellow_sign_in";
|
|
871
|
+
const ERROR_PARAM = "goodfellow_sign_in_error";
|
|
872
|
+
var SignInService = class {
|
|
873
|
+
url;
|
|
874
|
+
fetcher;
|
|
875
|
+
constructor(url, fetcher) {
|
|
876
|
+
this.url = url.replace(/\/$/, "");
|
|
877
|
+
this.fetcher = fetcher;
|
|
878
|
+
}
|
|
879
|
+
async answer(path, init) {
|
|
880
|
+
let response;
|
|
881
|
+
try {
|
|
882
|
+
response = await this.fetcher(`${this.url}${path}`, init);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
throw new SignInError("unavailable", `Couldn't reach the sign-in service: ${error.message}`);
|
|
885
|
+
}
|
|
886
|
+
const body = await response.json().catch(() => ({}));
|
|
887
|
+
if (!response.ok) throw signInProblem(body);
|
|
888
|
+
return body;
|
|
889
|
+
}
|
|
890
|
+
/** Posts JSON as plain text. */
|
|
891
|
+
post(path, body) {
|
|
892
|
+
return this.answer(path, {
|
|
893
|
+
method: "POST",
|
|
894
|
+
headers: { "content-type": "text/plain" },
|
|
895
|
+
body: JSON.stringify(body)
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
/** Posts JSON as plain text, and gives the answer whatever it is, for callers that explain problems of their own. */
|
|
899
|
+
async postAnswer(path, body) {
|
|
900
|
+
let response;
|
|
901
|
+
try {
|
|
902
|
+
response = await this.fetcher(`${this.url}${path}`, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: { "content-type": "text/plain" },
|
|
905
|
+
body: JSON.stringify(body)
|
|
906
|
+
});
|
|
907
|
+
} catch (error) {
|
|
908
|
+
throw new SignInError("unavailable", `Couldn't reach the sign-in service: ${error.message}`);
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
ok: response.ok,
|
|
912
|
+
body: await response.json().catch(() => ({}))
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
/** Whether the worker is set up and answering. Throws `SignInError` if it isn't. */
|
|
916
|
+
async health() {
|
|
917
|
+
if ((await this.answer("/health")).ok !== true) throw new SignInError("unavailable", "The sign-in service isn't set up.");
|
|
918
|
+
}
|
|
919
|
+
/** Whether this page can sign in to the repository through the worker. Throws `SignInError` if it can't. */
|
|
920
|
+
async check(repo, purpose) {
|
|
921
|
+
await this.answer(`/github/check?${new URLSearchParams({
|
|
922
|
+
repo,
|
|
923
|
+
purpose
|
|
924
|
+
})}`);
|
|
925
|
+
}
|
|
926
|
+
/** Where to send the browser to sign in, or with `install`, to install Goodfellow and sign in. */
|
|
927
|
+
startUrl(options) {
|
|
928
|
+
const query = new URLSearchParams({
|
|
929
|
+
purpose: options.purpose,
|
|
930
|
+
...options.repo && { repo: options.repo },
|
|
931
|
+
return: options.back,
|
|
932
|
+
challenge: options.challenge,
|
|
933
|
+
remember: options.remember ? "1" : "0",
|
|
934
|
+
...options.install && { install: "1" }
|
|
935
|
+
});
|
|
936
|
+
return `${this.url}/github/start?${query}`;
|
|
937
|
+
}
|
|
938
|
+
/** Exchanges the grant the worker came back with, and this tab's verifier, for a session (and for a site, its first token). */
|
|
939
|
+
session(grant, verifier) {
|
|
940
|
+
return this.post("/github/session", {
|
|
941
|
+
grant,
|
|
942
|
+
verifier
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
token(session) {
|
|
946
|
+
return this.post("/github/token", { session });
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
/** A token that renews itself through the worker before it expires, or when GitHub stops accepting it. */
|
|
950
|
+
function renewingToken(service, first, onRenew) {
|
|
951
|
+
let current = first;
|
|
952
|
+
let renewing;
|
|
953
|
+
return {
|
|
954
|
+
current: () => current,
|
|
955
|
+
async token({ refresh = false } = {}) {
|
|
956
|
+
if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
|
|
957
|
+
renewing ??= service.token(current.session).finally(() => {
|
|
958
|
+
renewing = void 0;
|
|
959
|
+
});
|
|
960
|
+
current = await renewing;
|
|
961
|
+
onRenew(current);
|
|
962
|
+
}
|
|
963
|
+
return current.token;
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/setup.ts
|
|
969
|
+
/** The branch the deploy setups build from. */
|
|
970
|
+
const MAIN = "main";
|
|
971
|
+
/**
|
|
972
|
+
* Links to GitHub's token pages with what creating a site needs: creating the
|
|
973
|
+
* repository (administration), writing its files and its build setup
|
|
974
|
+
* (contents and workflows), and turning on GitHub Pages (pages).
|
|
975
|
+
*/
|
|
976
|
+
function githubSetupTokenLinks(webUrl = "https://github.com") {
|
|
977
|
+
const fineGrained = new URLSearchParams({
|
|
978
|
+
name: "Goodfellow site setup",
|
|
979
|
+
description: "Create a Goodfellow site. It can be deleted once the site is created.",
|
|
980
|
+
expires_in: "7",
|
|
981
|
+
administration: "write",
|
|
982
|
+
contents: "write",
|
|
983
|
+
workflows: "write",
|
|
984
|
+
pages: "write"
|
|
985
|
+
});
|
|
986
|
+
const classic = new URLSearchParams({
|
|
987
|
+
scopes: "repo,workflow",
|
|
988
|
+
description: "Goodfellow site setup"
|
|
989
|
+
});
|
|
990
|
+
return {
|
|
991
|
+
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
992
|
+
classic: `${webUrl}/settings/tokens/new?${classic}`
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/** The steps after the repository exists, which are the same however it was created. */
|
|
996
|
+
function setupAccount(creator) {
|
|
997
|
+
/** Makes `main` the default branch, which the deploy setups build from. */
|
|
998
|
+
async function useMainBranch(api, repo) {
|
|
999
|
+
if (repo.default_branch === MAIN) return;
|
|
1000
|
+
const base = `/repos/${repoPath(repo.full_name)}`;
|
|
1001
|
+
const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
|
|
1002
|
+
await githubRequest(api, `${base}/git/refs`, {
|
|
1003
|
+
method: "POST",
|
|
1004
|
+
body: {
|
|
1005
|
+
ref: `refs/heads/${MAIN}`,
|
|
1006
|
+
sha: ref.object.sha
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
await githubRequest(api, base, {
|
|
1010
|
+
method: "PATCH",
|
|
1011
|
+
body: { default_branch: MAIN }
|
|
1012
|
+
});
|
|
1013
|
+
await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
|
|
1014
|
+
}
|
|
1015
|
+
/** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
|
|
1016
|
+
async function turnOnPages(api, repo) {
|
|
1017
|
+
try {
|
|
1018
|
+
return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
|
|
1019
|
+
method: "POST",
|
|
1020
|
+
body: { build_type: "workflow" }
|
|
1021
|
+
})).html_url;
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
if (error instanceof GitApiError && [
|
|
1024
|
+
403,
|
|
1025
|
+
404,
|
|
1026
|
+
409,
|
|
1027
|
+
422
|
|
1028
|
+
].includes(error.status)) return void 0;
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/** Writes every file in one commit, which replaces the starting README rather than following it. */
|
|
1033
|
+
async function writeFiles(api, repo, site) {
|
|
1034
|
+
const base = `/repos/${repoPath(repo.full_name)}`;
|
|
1035
|
+
const tree = [];
|
|
1036
|
+
for (const file of site.files) {
|
|
1037
|
+
if ("delete" in file) continue;
|
|
1038
|
+
if ("content" in file) {
|
|
1039
|
+
tree.push({
|
|
1040
|
+
path: file.path,
|
|
1041
|
+
mode: "100644",
|
|
1042
|
+
type: "blob",
|
|
1043
|
+
content: file.content
|
|
1044
|
+
});
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const blob = await githubJson(api, `${base}/git/blobs`, {
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
body: {
|
|
1050
|
+
content: encodeBase64Bytes(file.bytes),
|
|
1051
|
+
encoding: "base64"
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
tree.push({
|
|
1055
|
+
path: file.path,
|
|
1056
|
+
mode: "100644",
|
|
1057
|
+
type: "blob",
|
|
1058
|
+
sha: blob.sha
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
const created = await githubJson(api, `${base}/git/trees`, {
|
|
1063
|
+
method: "POST",
|
|
1064
|
+
body: { tree }
|
|
1065
|
+
});
|
|
1066
|
+
const commit = await githubJson(api, `${base}/git/commits`, {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
body: {
|
|
1069
|
+
message: site.message,
|
|
1070
|
+
tree: created.sha,
|
|
1071
|
+
parents: [],
|
|
1072
|
+
...creator.author && { author: {
|
|
1073
|
+
...creator.author,
|
|
1074
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
1075
|
+
} }
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
|
|
1079
|
+
method: "PATCH",
|
|
1080
|
+
body: {
|
|
1081
|
+
sha: commit.sha,
|
|
1082
|
+
force: true
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", "This sign-in can't write the site's files, or its build setup.");
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
|
|
1091
|
+
async function protectMain(api, repo) {
|
|
1092
|
+
return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
|
|
1093
|
+
method: "POST",
|
|
1094
|
+
body: {
|
|
1095
|
+
name: "Protect the published history",
|
|
1096
|
+
target: "branch",
|
|
1097
|
+
enforcement: "active",
|
|
1098
|
+
conditions: { ref_name: {
|
|
1099
|
+
include: ["~DEFAULT_BRANCH"],
|
|
1100
|
+
exclude: []
|
|
1101
|
+
} },
|
|
1102
|
+
rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
|
|
1103
|
+
},
|
|
1104
|
+
allow: [
|
|
1105
|
+
403,
|
|
1106
|
+
404,
|
|
1107
|
+
422
|
|
1108
|
+
]
|
|
1109
|
+
})).ok;
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
user: creator.user,
|
|
1113
|
+
owners: () => creator.owners(),
|
|
1114
|
+
isAvailable: (owner, name) => creator.isAvailable(owner, name),
|
|
1115
|
+
async createSite(site, onStep) {
|
|
1116
|
+
const warnings = [];
|
|
1117
|
+
onStep?.("repository");
|
|
1118
|
+
const { repo, api, signInInstallUrl } = await creator.create(site);
|
|
1119
|
+
await useMainBranch(api, repo);
|
|
1120
|
+
let siteUrl;
|
|
1121
|
+
if (site.pages) {
|
|
1122
|
+
onStep?.("pages");
|
|
1123
|
+
siteUrl = await turnOnPages(api, repo);
|
|
1124
|
+
if (!siteUrl) warnings.push("pages-unavailable");
|
|
1125
|
+
}
|
|
1126
|
+
onStep?.("files");
|
|
1127
|
+
await writeFiles(api, repo, site);
|
|
1128
|
+
onStep?.("protection");
|
|
1129
|
+
if (!await protectMain(api, repo)) warnings.push("protection-unavailable");
|
|
1130
|
+
if (signInInstallUrl) warnings.push("sign-in-not-installed");
|
|
1131
|
+
return {
|
|
1132
|
+
repo: repo.full_name,
|
|
1133
|
+
webUrl: repo.html_url,
|
|
1134
|
+
siteUrl,
|
|
1135
|
+
buildsUrl: `${repo.html_url}/actions`,
|
|
1136
|
+
pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
|
|
1137
|
+
...signInInstallUrl && { signInInstallUrl },
|
|
1138
|
+
warnings
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
/** Creating repositories with the person's own token. */
|
|
1144
|
+
async function tokenCreator(api) {
|
|
1145
|
+
const user = await githubJson(api, "/user");
|
|
1146
|
+
async function isAvailable(owner, name) {
|
|
1147
|
+
return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
user: {
|
|
1151
|
+
login: user.login,
|
|
1152
|
+
name: user.name ?? void 0,
|
|
1153
|
+
avatarUrl: user.avatar_url
|
|
1154
|
+
},
|
|
1155
|
+
async owners() {
|
|
1156
|
+
return [{
|
|
1157
|
+
path: user.login,
|
|
1158
|
+
name: user.name || user.login,
|
|
1159
|
+
kind: "personal"
|
|
1160
|
+
}, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
|
|
1161
|
+
path: org.login,
|
|
1162
|
+
name: org.login,
|
|
1163
|
+
kind: "organization"
|
|
1164
|
+
}))];
|
|
1165
|
+
},
|
|
1166
|
+
isAvailable,
|
|
1167
|
+
async create(site) {
|
|
1168
|
+
const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
|
|
1169
|
+
if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
|
|
1170
|
+
try {
|
|
1171
|
+
return {
|
|
1172
|
+
repo: await githubJson(api, path, {
|
|
1173
|
+
method: "POST",
|
|
1174
|
+
body: {
|
|
1175
|
+
name: site.name,
|
|
1176
|
+
private: site.private,
|
|
1177
|
+
description: site.description,
|
|
1178
|
+
auto_init: true
|
|
1179
|
+
}
|
|
1180
|
+
}),
|
|
1181
|
+
api
|
|
1182
|
+
};
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
|
|
1185
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create repositories in ${site.owner.path}.`);
|
|
1186
|
+
throw error;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Creating repositories through the sign-in worker, which uses the person's
|
|
1193
|
+
* GitHub token itself and gives back a token for the new repository only.
|
|
1194
|
+
*/
|
|
1195
|
+
function workerCreator(service, apiUrl, fetcher, signedIn) {
|
|
1196
|
+
let session = signedIn.session;
|
|
1197
|
+
async function call(path, body) {
|
|
1198
|
+
const answer = await service.postAnswer(path, {
|
|
1199
|
+
session,
|
|
1200
|
+
...body
|
|
1201
|
+
});
|
|
1202
|
+
if (typeof answer.body.session === "string") session = answer.body.session;
|
|
1203
|
+
if (!answer.ok) throw setupProblem(answer.body);
|
|
1204
|
+
return answer.body;
|
|
1205
|
+
}
|
|
1206
|
+
const { user } = signedIn;
|
|
1207
|
+
return {
|
|
1208
|
+
user: {
|
|
1209
|
+
login: user.login,
|
|
1210
|
+
name: user.name,
|
|
1211
|
+
avatarUrl: user.avatarUrl
|
|
1212
|
+
},
|
|
1213
|
+
author: {
|
|
1214
|
+
name: user.name || user.login,
|
|
1215
|
+
email: user.email
|
|
1216
|
+
},
|
|
1217
|
+
async owners() {
|
|
1218
|
+
const found = await call("/github/setup/owners", {});
|
|
1219
|
+
return (found.owners ?? []).map(({ installed, ...owner }) => ({
|
|
1220
|
+
...owner,
|
|
1221
|
+
...!installed && found.installUrl && { installUrl: found.installUrl }
|
|
1222
|
+
}));
|
|
1223
|
+
},
|
|
1224
|
+
async isAvailable(owner, name) {
|
|
1225
|
+
return (await call("/github/setup/available", {
|
|
1226
|
+
owner: owner.path,
|
|
1227
|
+
name
|
|
1228
|
+
})).available === true;
|
|
1229
|
+
},
|
|
1230
|
+
async create(site) {
|
|
1231
|
+
if (site.owner.installUrl) throw new SetupError("not-installed", `Goodfellow isn't installed on ${site.owner.path}.`, site.owner.installUrl);
|
|
1232
|
+
const created = await call("/github/setup/repository", {
|
|
1233
|
+
owner: site.owner.path,
|
|
1234
|
+
name: site.name,
|
|
1235
|
+
private: site.private,
|
|
1236
|
+
description: site.description
|
|
1237
|
+
});
|
|
1238
|
+
if (!created.repo || !created.token) throw new SignInError("unavailable", "The sign-in service didn't answer.");
|
|
1239
|
+
return {
|
|
1240
|
+
repo: created.repo,
|
|
1241
|
+
api: {
|
|
1242
|
+
apiUrl,
|
|
1243
|
+
token: created.token,
|
|
1244
|
+
fetch: fetcher
|
|
1245
|
+
},
|
|
1246
|
+
...created.editInstallUrl && { signInInstallUrl: created.editInstallUrl }
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/** The worker's answer when it couldn't create the repository, as a `SetupError` or `SignInError`. */
|
|
1252
|
+
function setupProblem(answer) {
|
|
1253
|
+
switch (answer.problem) {
|
|
1254
|
+
case "name-taken": return new SetupError("name-taken", "There's already a repository with that name.");
|
|
1255
|
+
case "name-invalid": return new SetupError("name-invalid", "GitHub can't use that as a repository name.");
|
|
1256
|
+
case "not-allowed": return new SetupError("not-allowed", "This sign-in can't create repositories there.");
|
|
1257
|
+
case "not-installed": return new SetupError("not-installed", "Goodfellow isn't installed there.", answer.installUrl);
|
|
1258
|
+
default: return signInProblem(answer);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
|
|
1262
|
+
function githubSetup(options = {}) {
|
|
1263
|
+
const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
1264
|
+
const links = githubSetupTokenLinks(options.webUrl);
|
|
1265
|
+
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1266
|
+
const location = options.location ?? browserLocation();
|
|
1267
|
+
const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
|
|
1268
|
+
const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
|
|
1269
|
+
const pending = credentialStorage("goodfellow:github:setup:pending", options.storage);
|
|
1270
|
+
return {
|
|
1271
|
+
name: "GitHub",
|
|
1272
|
+
canRedirect: service !== void 0,
|
|
1273
|
+
tokenLinks: [{
|
|
1274
|
+
url: links.fineGrained,
|
|
1275
|
+
label: "signIn.token.create"
|
|
1276
|
+
}, {
|
|
1277
|
+
url: links.classic,
|
|
1278
|
+
label: "signIn.token.createBroad",
|
|
1279
|
+
hint: "signIn.token.createBroadHint"
|
|
1280
|
+
}],
|
|
1281
|
+
async restore() {
|
|
1282
|
+
if (!location) return null;
|
|
1283
|
+
const url = new URL(location.href());
|
|
1284
|
+
const grant = url.searchParams.get(GRANT_PARAM);
|
|
1285
|
+
const failed = url.searchParams.get(ERROR_PARAM);
|
|
1286
|
+
if (!grant && !failed) return null;
|
|
1287
|
+
url.searchParams.delete(GRANT_PARAM);
|
|
1288
|
+
url.searchParams.delete(ERROR_PARAM);
|
|
1289
|
+
location.replace(url.toString());
|
|
1290
|
+
const verifier = pending.load();
|
|
1291
|
+
pending.clear();
|
|
1292
|
+
if (failed || !grant || !verifier || !service) throw signInProblem({ problem: failed ?? "cancelled" });
|
|
1293
|
+
const signedIn = await service.session(grant, verifier);
|
|
1294
|
+
return setupAccount(workerCreator(service, apiUrl, fetcher, signedIn));
|
|
1295
|
+
},
|
|
1296
|
+
async signInWithToken(token) {
|
|
1297
|
+
const trimmed = token.trim();
|
|
1298
|
+
if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
|
|
1299
|
+
return setupAccount(await tokenCreator({
|
|
1300
|
+
apiUrl,
|
|
1301
|
+
token: trimmed,
|
|
1302
|
+
fetch: fetcher
|
|
1303
|
+
}));
|
|
1304
|
+
},
|
|
1305
|
+
async startRedirect({ install } = {}) {
|
|
1306
|
+
if (!service || !location) throw new SignInError("unavailable", "Signing in with GitHub isn't available here.");
|
|
1307
|
+
await service.health();
|
|
1308
|
+
const { verifier, challenge } = await newVerifier();
|
|
1309
|
+
pending.save(verifier, false);
|
|
1310
|
+
location.assign(service.startUrl({
|
|
1311
|
+
purpose: "setup",
|
|
1312
|
+
back: location.href(),
|
|
1313
|
+
challenge,
|
|
1314
|
+
remember: false,
|
|
1315
|
+
install
|
|
1316
|
+
}));
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
//#endregion
|
|
174
1321
|
//#region src/index.ts
|
|
1322
|
+
/** Saved sign-ins to forget when they stop working: not when the worker can't be reached, which passes. */
|
|
1323
|
+
function forgets(error) {
|
|
1324
|
+
return error instanceof SignInError && error.problem !== "unavailable";
|
|
1325
|
+
}
|
|
175
1326
|
/**
|
|
176
1327
|
* Links to GitHub's token page with everything filled in except the
|
|
177
1328
|
* repository, which GitHub doesn't let links choose.
|
|
178
1329
|
*/
|
|
179
1330
|
function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
180
|
-
const [
|
|
1331
|
+
const [repoOwner = ""] = repo.split("/");
|
|
181
1332
|
const fineGrained = new URLSearchParams({
|
|
182
1333
|
name: "Goodfellow admin",
|
|
183
1334
|
description: `Edit ${repo} in its admin panel`,
|
|
184
|
-
target_name:
|
|
1335
|
+
target_name: repoOwner,
|
|
185
1336
|
expires_in: "90",
|
|
186
1337
|
contents: "write",
|
|
187
|
-
deployments: "read"
|
|
1338
|
+
deployments: "read",
|
|
1339
|
+
actions: "read"
|
|
188
1340
|
});
|
|
189
1341
|
const classic = new URLSearchParams({
|
|
190
1342
|
scopes: "repo",
|
|
191
1343
|
description: `Goodfellow admin for ${repo}`
|
|
192
1344
|
});
|
|
1345
|
+
const owner = new URLSearchParams({
|
|
1346
|
+
name: "Goodfellow owner",
|
|
1347
|
+
description: `Manage the editors, domain and updates of ${repo}`,
|
|
1348
|
+
target_name: repoOwner,
|
|
1349
|
+
expires_in: "1",
|
|
1350
|
+
administration: "write",
|
|
1351
|
+
pages: "write",
|
|
1352
|
+
actions: "write"
|
|
1353
|
+
});
|
|
193
1354
|
return {
|
|
194
1355
|
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
195
|
-
classic: `${webUrl}/settings/tokens/new?${classic}
|
|
1356
|
+
classic: `${webUrl}/settings/tokens/new?${classic}`,
|
|
1357
|
+
owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
|
|
196
1358
|
};
|
|
197
1359
|
}
|
|
198
1360
|
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
@@ -201,11 +1363,151 @@ function github(options) {
|
|
|
201
1363
|
const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
202
1364
|
const links = githubTokenLinks(options.repo, options.webUrl);
|
|
203
1365
|
const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
|
|
1366
|
+
const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
|
|
1367
|
+
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1368
|
+
const location = options.location ?? browserLocation();
|
|
1369
|
+
const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
|
|
1370
|
+
const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
|
|
1371
|
+
function save(value, remember) {
|
|
1372
|
+
storage.save(value.type === "token" ? value.token : JSON.stringify(value), remember);
|
|
1373
|
+
}
|
|
1374
|
+
function loadSaved() {
|
|
1375
|
+
const value = storage.load();
|
|
1376
|
+
if (!value) return void 0;
|
|
1377
|
+
try {
|
|
1378
|
+
const parsed = JSON.parse(value);
|
|
1379
|
+
if (parsed.type === "worker" || parsed.type === "token") return parsed;
|
|
1380
|
+
} catch {}
|
|
1381
|
+
return {
|
|
1382
|
+
type: "token",
|
|
1383
|
+
token: value
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Sends the browser to GitHub through the worker, which comes back to this
|
|
1388
|
+
* screen. With `install`, GitHub's install page installs Goodfellow first.
|
|
1389
|
+
*/
|
|
1390
|
+
async function redirect(purpose, remember, { install } = {}) {
|
|
1391
|
+
if (!service || !location) throw new SignInError("unavailable", "Signing in with GitHub isn't available here.");
|
|
1392
|
+
if (!install) await service.check(options.repo, purpose);
|
|
1393
|
+
const { verifier, challenge } = await newVerifier();
|
|
1394
|
+
pending.save(JSON.stringify({
|
|
1395
|
+
verifier,
|
|
1396
|
+
purpose,
|
|
1397
|
+
remember
|
|
1398
|
+
}), false);
|
|
1399
|
+
location.assign(service.startUrl({
|
|
1400
|
+
purpose,
|
|
1401
|
+
repo: options.repo,
|
|
1402
|
+
back: location.href(),
|
|
1403
|
+
challenge,
|
|
1404
|
+
remember,
|
|
1405
|
+
install
|
|
1406
|
+
}));
|
|
1407
|
+
}
|
|
1408
|
+
/** Connects with a session from the worker, whose tokens renew themselves. */
|
|
1409
|
+
async function connectWorker(issued, remember) {
|
|
1410
|
+
const source = renewingToken(service, issued, (renewed) => save({
|
|
1411
|
+
type: "worker",
|
|
1412
|
+
session: renewed.session
|
|
1413
|
+
}, remember));
|
|
1414
|
+
save({
|
|
1415
|
+
type: "worker",
|
|
1416
|
+
session: issued.session
|
|
1417
|
+
}, remember);
|
|
1418
|
+
const api = {
|
|
1419
|
+
apiUrl,
|
|
1420
|
+
token: source.token,
|
|
1421
|
+
fetch: fetcher
|
|
1422
|
+
};
|
|
1423
|
+
const repo = await githubJson(api, `/repos/${repoPath(options.repo)}`);
|
|
1424
|
+
const { user } = issued;
|
|
1425
|
+
return new GitHubBackend({
|
|
1426
|
+
api,
|
|
1427
|
+
repo: options.repo,
|
|
1428
|
+
branch: options.branch ?? repo.default_branch,
|
|
1429
|
+
user: {
|
|
1430
|
+
login: user.login,
|
|
1431
|
+
name: user.name,
|
|
1432
|
+
avatarUrl: user.avatarUrl
|
|
1433
|
+
},
|
|
1434
|
+
author: {
|
|
1435
|
+
name: user.name || user.login,
|
|
1436
|
+
email: user.email
|
|
1437
|
+
},
|
|
1438
|
+
onSignOut: () => {
|
|
1439
|
+
storage.clear();
|
|
1440
|
+
revokeToken(source.current().token);
|
|
1441
|
+
},
|
|
1442
|
+
ownerTokenLink: {
|
|
1443
|
+
url: links.owner,
|
|
1444
|
+
label: "owner.token.create"
|
|
1445
|
+
},
|
|
1446
|
+
ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions)
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/** Revokes a token the worker gave, as signing out does. Never fails: signing out goes ahead regardless. */
|
|
1450
|
+
function revokeToken(token) {
|
|
1451
|
+
githubRequest({
|
|
1452
|
+
apiUrl,
|
|
1453
|
+
token,
|
|
1454
|
+
fetch: fetcher
|
|
1455
|
+
}, "/installation/token", { method: "DELETE" }).catch(() => {});
|
|
1456
|
+
}
|
|
1457
|
+
/** Gives the backend an owner's sign-in that has just come back from GitHub. It's kept in memory only. */
|
|
1458
|
+
async function useOwner(backend, grant, verifier) {
|
|
1459
|
+
const worker = service;
|
|
1460
|
+
try {
|
|
1461
|
+
const source = renewingToken(worker, await worker.session(grant, verifier), () => {});
|
|
1462
|
+
backend.useOwnerSignIn(source.token, () => revokeToken(source.current().token));
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
backend.ownerSignInFailed(error);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
/** Finishes a sign-in through the worker if the page has just come back from it. */
|
|
1468
|
+
async function completeRedirect() {
|
|
1469
|
+
if (!location) return void 0;
|
|
1470
|
+
const url = new URL(location.href());
|
|
1471
|
+
const grant = url.searchParams.get(GRANT_PARAM);
|
|
1472
|
+
const failed = url.searchParams.get(ERROR_PARAM);
|
|
1473
|
+
if (!grant && !failed) return void 0;
|
|
1474
|
+
url.searchParams.delete(GRANT_PARAM);
|
|
1475
|
+
url.searchParams.delete(ERROR_PARAM);
|
|
1476
|
+
location.replace(url.toString());
|
|
1477
|
+
const saved = pending.load();
|
|
1478
|
+
pending.clear();
|
|
1479
|
+
const redirect = saved ? JSON.parse(saved) : void 0;
|
|
1480
|
+
if (!redirect || !service) throw new SignInError("redirect-failed", "Signing in with GitHub didn't finish in this tab.");
|
|
1481
|
+
if (redirect.purpose === "owner") {
|
|
1482
|
+
const backend = await restoreSaved();
|
|
1483
|
+
if (!backend) throw new SignInError("redirect-failed", "Sign in again, then sign in as an owner.");
|
|
1484
|
+
if (failed) backend.ownerSignInFailed(signInProblem({ problem: failed }));
|
|
1485
|
+
else if (grant) await useOwner(backend, grant, redirect.verifier);
|
|
1486
|
+
return backend;
|
|
1487
|
+
}
|
|
1488
|
+
if (failed || !grant) throw signInProblem({ problem: failed });
|
|
1489
|
+
return connectWorker(await service.session(grant, redirect.verifier), redirect.remember);
|
|
1490
|
+
}
|
|
1491
|
+
async function restoreSaved() {
|
|
1492
|
+
const saved = loadSaved();
|
|
1493
|
+
if (!saved) return null;
|
|
1494
|
+
try {
|
|
1495
|
+
if (saved.type === "token") return await connect(saved.token);
|
|
1496
|
+
if (!service) {
|
|
1497
|
+
storage.clear();
|
|
1498
|
+
return null;
|
|
1499
|
+
}
|
|
1500
|
+
return await connectWorker(await service.token(saved.session), storage.remembered());
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
if (forgets(error)) storage.clear();
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
204
1506
|
async function connect(token) {
|
|
205
1507
|
const api = {
|
|
206
1508
|
apiUrl,
|
|
207
1509
|
token,
|
|
208
|
-
fetch:
|
|
1510
|
+
fetch: fetcher
|
|
209
1511
|
};
|
|
210
1512
|
const user = await githubJson(api, "/user");
|
|
211
1513
|
let repo;
|
|
@@ -225,12 +1527,17 @@ function github(options) {
|
|
|
225
1527
|
name: user.name ?? void 0,
|
|
226
1528
|
avatarUrl: user.avatar_url
|
|
227
1529
|
},
|
|
228
|
-
onSignOut: () => storage.clear()
|
|
1530
|
+
onSignOut: () => storage.clear(),
|
|
1531
|
+
ownerTokenLink: {
|
|
1532
|
+
url: links.owner,
|
|
1533
|
+
label: "owner.token.create"
|
|
1534
|
+
},
|
|
1535
|
+
...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
|
|
229
1536
|
});
|
|
230
1537
|
}
|
|
231
1538
|
return {
|
|
232
1539
|
name: "GitHub",
|
|
233
|
-
canRedirect:
|
|
1540
|
+
canRedirect: service !== void 0,
|
|
234
1541
|
tokenLinks: [{
|
|
235
1542
|
url: links.fineGrained,
|
|
236
1543
|
label: "signIn.token.create"
|
|
@@ -240,26 +1547,24 @@ function github(options) {
|
|
|
240
1547
|
hint: "signIn.token.createBroadHint"
|
|
241
1548
|
}],
|
|
242
1549
|
async restore() {
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
return await connect(token);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
if (error instanceof SignInError) storage.clear();
|
|
249
|
-
throw error;
|
|
250
|
-
}
|
|
1550
|
+
const redirected = await completeRedirect();
|
|
1551
|
+
if (redirected !== void 0) return redirected;
|
|
1552
|
+
return restoreSaved();
|
|
251
1553
|
},
|
|
252
1554
|
async signInWithToken(token, remember) {
|
|
253
1555
|
const trimmed = token.trim();
|
|
254
1556
|
if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
|
|
255
1557
|
const backend = await connect(trimmed);
|
|
256
|
-
|
|
1558
|
+
save({
|
|
1559
|
+
type: "token",
|
|
1560
|
+
token: trimmed
|
|
1561
|
+
}, remember);
|
|
257
1562
|
return backend;
|
|
258
1563
|
},
|
|
259
|
-
|
|
260
|
-
|
|
1564
|
+
startRedirect(remember, redirectOptions) {
|
|
1565
|
+
return redirect("edit", remember, redirectOptions);
|
|
261
1566
|
}
|
|
262
1567
|
};
|
|
263
1568
|
}
|
|
264
1569
|
//#endregion
|
|
265
|
-
export { GitHubBackend, github, githubTokenLinks };
|
|
1570
|
+
export { GOODFELLOW_SIGN_IN, GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
|