@magland/mochi 0.3.1 → 0.4.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.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +138 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +192 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. package/package.json +4 -4
package/dist/git.js CHANGED
@@ -4,7 +4,9 @@ exports.GitRepo = exports.GitError = void 0;
4
4
  exports.execGit = execGit;
5
5
  exports.execGitStatus = execGitStatus;
6
6
  exports.isValidRefName = isValidRefName;
7
+ exports.isValidNewRefName = isValidNewRefName;
7
8
  exports.isValidRepoPath = isValidRepoPath;
9
+ exports.isValidNewRepoPath = isValidNewRepoPath;
8
10
  exports.isValidSha = isValidSha;
9
11
  const child_process_1 = require("child_process");
10
12
  const MAX_BUFFER = 256 * 1024 * 1024;
@@ -55,6 +57,32 @@ function isValidRefName(ref) {
55
57
  return false;
56
58
  return true;
57
59
  }
60
+ /**
61
+ * Whether a name may become a new branch or tag. Stricter than isValidRefName,
62
+ * which stays permissive because it also vets names being looked up:
63
+ *
64
+ * - `HEAD` and git's other pseudo-refs (FETCH_HEAD, MERGE_HEAD, ...) resolve
65
+ * ahead of any branch or tag of the same name, so a ref that could be
66
+ * created under one would be ambiguous everywhere it is typed and warned
67
+ * about on every clone.
68
+ * - A `refs/` prefix means the caller pasted a full ref name; creating it
69
+ * verbatim would silently nest (refs/heads/refs/heads/x).
70
+ * - 200 characters keeps the loose-ref file under every common filesystem's
71
+ * 255-byte name limit, so a too-long name is refused here with a clear
72
+ * message rather than failing inside git.
73
+ * - A leading dash could read as an option to a git command.
74
+ */
75
+ function isValidNewRefName(name) {
76
+ if (!isValidRefName(name) || name.startsWith('-'))
77
+ return false;
78
+ if (name.length > 200)
79
+ return false;
80
+ if (name === 'HEAD' || /^[A-Z_]+_HEAD$/.test(name))
81
+ return false;
82
+ if (name === 'refs' || name.startsWith('refs/'))
83
+ return false;
84
+ return true;
85
+ }
58
86
  function isValidRepoPath(p) {
59
87
  if (p === '')
60
88
  return true;
@@ -64,6 +92,24 @@ function isValidRepoPath(p) {
64
92
  return false;
65
93
  return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
66
94
  }
95
+ /**
96
+ * Whether a path may be written into a repository. Stricter than
97
+ * isValidRepoPath, which also vets paths being read and so must keep serving
98
+ * whatever a repository already holds:
99
+ *
100
+ * - a `.git` component (in any case) is refused, because git itself will
101
+ * not take it in a tree and the failure would otherwise surface deep in
102
+ * the plumbing rather than as a validation error;
103
+ * - a backslash is refused, because a path carrying one cannot be checked
104
+ * out on Windows and is almost always a Windows separator pasted whole.
105
+ */
106
+ function isValidNewRepoPath(p) {
107
+ if (!isValidRepoPath(p))
108
+ return false;
109
+ if (p.includes('\\'))
110
+ return false;
111
+ return p.split('/').every((seg) => seg.toLowerCase() !== '.git');
112
+ }
67
113
  function isValidSha(s) {
68
114
  return /^[0-9a-f]{40}$/i.test(s);
69
115
  }
@@ -146,8 +192,65 @@ class GitRepo {
146
192
  async catBlob(ref, path) {
147
193
  return execGit(this.dir, ['cat-file', 'blob', `${ref}:${path}`]);
148
194
  }
195
+ /**
196
+ * The blob at a path: its object id and size, or null if there is no blob
197
+ * there. Asked before catBlob wherever the answer decides whether to read at
198
+ * all, since catBlob holds the whole object in memory and a repository may
199
+ * hold objects the size of the machine.
200
+ */
201
+ async blobInfo(ref, path) {
202
+ let out;
203
+ try {
204
+ out = (await execGit(this.dir, ['ls-tree', '-z', '-l', ref, '--', path])).toString('utf8');
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ // ls-tree lists what the path names; a directory lists its own entry,
210
+ // typed tree, and is not a blob.
211
+ const m = out.split('\0')[0]?.match(/^(\S+) (\S+) (\S+) +(\S+)\t/);
212
+ if (!m || m[2] !== 'blob')
213
+ return null;
214
+ const size = parseInt(m[4], 10);
215
+ return Number.isFinite(size) ? { sha: m[3], size } : null;
216
+ }
217
+ /**
218
+ * Write a blob's bytes to a stream without holding them. Resolves when git
219
+ * has finished or the destination has gone; rejects only if git could not
220
+ * start or exited in error before writing anything. A destination that
221
+ * closes early has git killed rather than left blocked on a full pipe.
222
+ */
223
+ streamBlob(ref, path, out) {
224
+ return new Promise((resolve, reject) => {
225
+ const child = (0, child_process_1.spawn)('git', ['-C', this.dir, 'cat-file', 'blob', `${ref}:${path}`], {
226
+ stdio: ['ignore', 'pipe', 'pipe'],
227
+ });
228
+ let stderr = '';
229
+ let wrote = false;
230
+ child.stderr.on('data', (d) => {
231
+ if (stderr.length < 4096)
232
+ stderr += d.toString();
233
+ });
234
+ child.stdout.once('data', () => {
235
+ wrote = true;
236
+ });
237
+ child.stdout.pipe(out, { end: true });
238
+ out.on('close', () => {
239
+ if (child.exitCode === null)
240
+ child.kill();
241
+ resolve();
242
+ });
243
+ child.on('error', (e) => reject(new GitError(`git cat-file failed: ${e.message}`)));
244
+ child.on('close', (code) => {
245
+ if (code === 0 || wrote)
246
+ resolve();
247
+ else
248
+ reject(new GitError(`git cat-file failed: ${stderr.trim() || `exit ${code}`}`));
249
+ });
250
+ });
251
+ }
149
252
  async log(ref, skip, limit, path, author) {
150
- const fmt = '%H%x00%an%x00%aI%x00%s';
253
+ const fmt = '%H%x00%an%x00%ae%x00%aI%x00%s';
151
254
  const args = ['log', `--format=${fmt}`, '-n', String(limit), `--skip=${skip}`];
152
255
  // -F makes --author a literal string rather than a pattern, so a name
153
256
  // with a dot or a plus in it means itself.
@@ -164,8 +267,8 @@ class GitRepo {
164
267
  return [];
165
268
  }
166
269
  return ls.map((l) => {
167
- const [sha, author, date, subject] = l.split('\0');
168
- return { sha, author, date, subject: subject ?? '' };
270
+ const [sha, author, email, date, subject] = l.split('\0');
271
+ return { sha, author, email, date, subject: subject ?? '' };
169
272
  });
170
273
  }
171
274
  /**
@@ -220,7 +323,7 @@ class GitRepo {
220
323
  // Renames are wanted as renames rather than as an add and a delete, so
221
324
  // that both names date from the same commit.
222
325
  '--find-renames',
223
- '--format=%x01%H%x00%an%x00%aI%x00%s',
326
+ '--format=%x01%H%x00%an%x00%ae%x00%aI%x00%s',
224
327
  ref,
225
328
  '--',
226
329
  ];
@@ -274,13 +377,13 @@ class GitRepo {
274
377
  if (!record)
275
378
  continue;
276
379
  const parts = record.split('\0');
277
- const [sha, author, date, subject] = parts;
380
+ const [sha, author, email, date, subject] = parts;
278
381
  if (!sha)
279
382
  continue;
280
- const commit = { sha, author, date, subject: subject ?? '' };
281
- // parts[4] onwards is the diff: a status, then the one path it renames
383
+ const commit = { sha, author, email, date, subject: subject ?? '' };
384
+ // parts[5] onwards is the diff: a status, then the one path it renames
282
385
  // from and to, or the one path it touches.
283
- for (let i = 4; i < parts.length; i++) {
386
+ for (let i = 5; i < parts.length; i++) {
284
387
  const status = parts[i].replace(/^\n/, '');
285
388
  if (!status)
286
389
  continue;
@@ -316,7 +419,10 @@ class GitRepo {
316
419
  const fmt = '%H%x00%an%x00%ae%x00%aI%x00%P%x00%B';
317
420
  let out;
318
421
  try {
319
- out = (await execGit(this.dir, ['log', '-1', `--format=${fmt}`, sha, '--'])).toString('utf8');
422
+ // --end-of-options, so that a revision beginning with a dash is a
423
+ // revision git fails to find rather than an option it obeys: `--output`
424
+ // would otherwise write this listing wherever the caller said.
425
+ out = (await execGit(this.dir, ['log', '-1', `--format=${fmt}`, '--end-of-options', sha, '--'])).toString('utf8');
320
426
  }
321
427
  catch {
322
428
  return null;
@@ -394,6 +500,7 @@ class GitRepo {
394
500
  lines.push({
395
501
  sha: cur.sha ?? '',
396
502
  author: cur.author ?? '',
503
+ email: cur.email ?? '',
397
504
  date: cur.time ? new Date(cur.time * 1000).toISOString() : '',
398
505
  summary: cur.summary ?? '',
399
506
  previous: cur.previous ?? null,
@@ -412,6 +519,10 @@ class GitRepo {
412
519
  const value = sp === -1 ? '' : line.slice(sp + 1);
413
520
  if (key === 'author')
414
521
  cur.author = value;
522
+ // The porcelain wraps the address in angle brackets; the brackets are
523
+ // its framing, not part of the address.
524
+ else if (key === 'author-mail')
525
+ cur.email = value.replace(/^<|>$/g, '');
415
526
  else if (key === 'author-time')
416
527
  cur.time = parseInt(value, 10);
417
528
  else if (key === 'summary')
@@ -433,7 +544,7 @@ class GitRepo {
433
544
  * a reader almost always means.
434
545
  */
435
546
  async compare(base, head, limit = 250) {
436
- const fmt = '%H%x00%an%x00%aI%x00%s';
547
+ const fmt = '%H%x00%an%x00%ae%x00%aI%x00%s';
437
548
  const [counts, log, patch, mergeBase] = await Promise.all([
438
549
  execGit(this.dir, ['rev-list', '--left-right', '--count', `${base}...${head}`])
439
550
  .then((b) => b.toString('utf8').trim().split(/\s+/))
@@ -452,8 +563,8 @@ class GitRepo {
452
563
  .split('\n')
453
564
  .filter((l) => l !== '')
454
565
  .map((l) => {
455
- const [sha, author, date, subject] = l.split('\0');
456
- return { sha, author, date, subject: subject ?? '' };
566
+ const [sha, author, email, date, subject] = l.split('\0');
567
+ return { sha, author, email, date, subject: subject ?? '' };
457
568
  });
458
569
  return {
459
570
  behind: parseInt(counts[0], 10) || 0,
@@ -485,7 +596,7 @@ class GitRepo {
485
596
  return people;
486
597
  }
487
598
  async commitPatch(sha) {
488
- return (await execGit(this.dir, ['show', '--format=', '--patch', '--no-color', sha, '--'])).toString('utf8');
599
+ return (await execGit(this.dir, ['show', '--format=', '--patch', '--no-color', '--end-of-options', sha, '--'])).toString('utf8');
489
600
  }
490
601
  /**
491
602
  * Stream `git archive` for `ref` into `out`, as GitHub's source downloads
package/dist/githttp.js CHANGED
@@ -38,12 +38,14 @@ exports.checkReadAuth = checkReadAuth;
38
38
  exports.checkPushAuth = checkPushAuth;
39
39
  exports.registerGitHttp = registerGitHttp;
40
40
  const child_process_1 = require("child_process");
41
+ const stream = __importStar(require("stream"));
41
42
  const zlib = __importStar(require("zlib"));
42
43
  const git_1 = require("./git");
43
44
  const jobtoken_1 = require("./jobtoken");
44
45
  const ops_1 = require("./ops");
45
46
  const perms_1 = require("./perms");
46
47
  const scan_1 = require("./scan");
48
+ const sitepublish_1 = require("./sitepublish");
47
49
  const limit_1 = require("./limit");
48
50
  const vault_1 = require("./vault");
49
51
  const web_1 = require("./web");
@@ -168,10 +170,23 @@ function checkPushAuth(root, limiter, req, collection, repoName, repo) {
168
170
  if ('ok' in checked)
169
171
  return checked;
170
172
  const auth = checked.auth;
173
+ // One refusal, in one wording, for a private repository the credential has
174
+ // no role in and for a name that does not exist in a collection it may not
175
+ // create in. The two used to differ -- 404 for the first, 403 naming the
176
+ // user for the second -- which let any signed-in user list a collection's
177
+ // private repositories by pushing at guessed names and reading which code
178
+ // came back. The sentence is true of both: whoever may create in a
179
+ // collection owns it, and an owner has the admin role on every repository
180
+ // in it, so nobody who could create here is ever refused a private one.
181
+ const notFound = {
182
+ ok: false,
183
+ status: 404,
184
+ message: 'repository not found (a push to a name that does not exist creates it only in a collection you own)',
185
+ };
171
186
  if (repo) {
172
187
  const role = (0, perms_1.repoRole)(root, auth, repo);
173
188
  if (role === null)
174
- return { ok: false, status: 404, message: 'repository not found' };
189
+ return notFound;
175
190
  if (!(0, perms_1.atLeast)(role, 'write')) {
176
191
  return {
177
192
  ok: false,
@@ -181,13 +196,8 @@ function checkPushAuth(root, limiter, req, collection, repoName, repo) {
181
196
  }
182
197
  return { ok: true, auth };
183
198
  }
184
- if (!(0, perms_1.canCreateRepo)(root, auth, collection, repoName)) {
185
- return {
186
- ok: false,
187
- status: 403,
188
- message: `user ${auth.username} is not allowed to create ${collection}/${repoName}`,
189
- };
190
- }
199
+ if (!(0, perms_1.canCreateRepo)(root, auth, collection, repoName))
200
+ return notFound;
191
201
  return { ok: true, auth };
192
202
  }
193
203
  // The refs a push may have changed, read before and after receive-pack. A
@@ -305,15 +315,35 @@ function registerGitHttp(app, root, gates, authLimiter, engine) {
305
315
  res.setHeader('Content-Type', `application/x-${service}-result`);
306
316
  res.setHeader('Cache-Control', 'no-cache');
307
317
  const child = (0, child_process_1.spawn)('git', [service.slice(4), '--stateless-rpc', dir], { env: gitEnv(req) });
308
- let body = req;
309
- if (req.headers['content-encoding'] === 'gzip') {
310
- body = req.pipe(zlib.createGunzip());
311
- }
312
- body.pipe(child.stdin);
318
+ // The request body reaches git through a pipeline rather than a chain of
319
+ // pipe() calls, because pipe() leaves an error on the destination with no
320
+ // listener, and an 'error' nobody listens for ends the process. A body
321
+ // declared gzip that is not gzip is the case that reaches here from the
322
+ // network: it is a bad request, and what should die is the request.
323
+ // Likewise git exiting early, which turns its stdin into an EPIPE for the
324
+ // writer: the exit code is the answer, and the pipe error is noise.
325
+ const stages = [req];
326
+ if (req.headers['content-encoding'] === 'gzip')
327
+ stages.push(zlib.createGunzip());
328
+ stream.pipeline([...stages, child.stdin], (err) => {
329
+ if (err)
330
+ child.kill();
331
+ });
313
332
  child.stdout.pipe(res);
333
+ // A client that goes away mid-transfer leaves git writing into a pipe
334
+ // nobody drains. Node unpipes and pauses the source, which is a git
335
+ // process blocked forever, holding its packfile in memory, one per
336
+ // abandoned clone. A response closed without finishing is that case;
337
+ // one that finished has a git that is already exiting.
338
+ res.on('close', () => {
339
+ if (!res.writableFinished)
340
+ child.kill();
341
+ });
314
342
  child.on('error', () => {
315
343
  release();
316
- res.status(500).end();
344
+ if (!res.headersSent)
345
+ res.status(500);
346
+ res.end();
317
347
  });
318
348
  child.on('close', (code) => {
319
349
  release();
@@ -359,7 +389,19 @@ function registerGitHttp(app, root, gates, authLimiter, engine) {
359
389
  res.status(400).type('text/plain').send('collection names may not begin with a dot\n');
360
390
  return;
361
391
  }
362
- repo = await (0, ops_1.createRepo)(root, collectionName, repoName);
392
+ // The ops layer applies the rest of what a new name must satisfy
393
+ // (length, case collisions with existing names); its refusal is an
394
+ // answer for the pusher, not a failure of the server.
395
+ try {
396
+ repo = await (0, ops_1.createRepo)(root, collectionName, repoName);
397
+ }
398
+ catch (e) {
399
+ if (e instanceof ops_1.OpError) {
400
+ res.status((0, ops_1.opErrorStatus)(e.kind)).type('text/plain').send(`${e.message}\n`);
401
+ return;
402
+ }
403
+ throw e;
404
+ }
363
405
  }
364
406
  await advertise(req, res, 'git-receive-pack', repo.dir);
365
407
  return;
@@ -397,7 +439,18 @@ function registerGitHttp(app, root, gates, authLimiter, engine) {
397
439
  res.status(400).type('text/plain').send('collection names may not begin with a dot\n');
398
440
  return;
399
441
  }
400
- repo = await (0, ops_1.createRepo)(root, collectionName, repoName);
442
+ // As in the advertisement above: the ops layer's refusal of a new
443
+ // name is an answer for the pusher.
444
+ try {
445
+ repo = await (0, ops_1.createRepo)(root, collectionName, repoName);
446
+ }
447
+ catch (e) {
448
+ if (e instanceof ops_1.OpError) {
449
+ res.status((0, ops_1.opErrorStatus)(e.kind)).type('text/plain').send(`${e.message}\n`);
450
+ return;
451
+ }
452
+ throw e;
453
+ }
401
454
  }
402
455
  const target = repo;
403
456
  const actor = auth.username;
@@ -409,20 +462,24 @@ function registerGitHttp(app, root, gates, authLimiter, engine) {
409
462
  if (code !== 0)
410
463
  return;
411
464
  ensureHead(target)
412
- .then(() => (engine ? firePushEvents(engine, target, before, actor) : undefined))
465
+ .then(() => firePushEvents(root, engine, target, before, actor))
413
466
  .catch((e) => console.error(`post-receive handling failed: ${e instanceof Error ? e.message : e}`));
414
467
  });
415
468
  }));
416
469
  }
417
- // Turn a before/after ref snapshot into push events for the CI engine. Ref
418
- // deletions are reported (with an all-zero "after") and ignored downstream;
419
- // a workflow file that fails to parse still produces a visible failed run,
420
- // which is why nothing here filters on content.
421
- async function firePushEvents(engine, repo, before, actor) {
470
+ // Turn a before/after ref snapshot into push events: a site published from the
471
+ // repository is rewritten when its branch moved, and the CI engine, when there
472
+ // is one, is told about every moved ref. Ref deletions are reported (with an
473
+ // all-zero "after") and ignored downstream; a workflow file that fails to
474
+ // parse still produces a visible failed run, which is why nothing here
475
+ // filters on content.
476
+ async function firePushEvents(root, engine, repo, before, actor) {
422
477
  const after = await refSnapshot(repo);
423
478
  for (const [ref, sha] of after) {
424
479
  if (before.get(ref) === sha)
425
480
  continue;
426
- await engine.handlePush(repo, { ref, before: before.get(ref) ?? ZERO, after: sha, actor });
481
+ await (0, sitepublish_1.publishSiteAfterPush)(root, repo, ref);
482
+ if (engine)
483
+ await engine.handlePush(repo, { ref, before: before.get(ref) ?? ZERO, after: sha, actor });
427
484
  }
428
485
  }
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GITHUB_SECRET_FILE = void 0;
37
+ exports.overrideGithubEndpointsForTests = overrideGithubEndpointsForTests;
38
+ exports.readGithubSecret = readGithubSecret;
39
+ exports.writeGithubSecret = writeGithubSecret;
40
+ exports.clearGithubSecret = clearGithubSecret;
41
+ exports.githubConfigured = githubConfigured;
42
+ exports.githubAuthorizeUrl = githubAuthorizeUrl;
43
+ exports.exchangeGithubCode = exchangeGithubCode;
44
+ exports.fetchGithubUser = fetchGithubUser;
45
+ exports.isPlausibleGithubLogin = isPlausibleGithubLogin;
46
+ exports.lookupGithubLogin = lookupGithubLogin;
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
49
+ const config_1 = require("./config");
50
+ // Sign-in with GitHub: the OAuth code flow against github.com, and the small
51
+ // outbound calls it needs. These are the only requests this server makes to
52
+ // another host, they happen only when an administrator has configured the
53
+ // feature, and nothing GitHub returns is stored: the access token is used for
54
+ // one request -- who signed in -- and dropped, so the vault holds no GitHub
55
+ // token. Who may sign in, and the gh:<id> session binding the callback mints,
56
+ // live in src/vault.ts beside the other credentials; the routes are in
57
+ // src/webops.ts with the other sign-in flows.
58
+ /**
59
+ * Where the OAuth App's client secret is kept: a mode-600 file beside
60
+ * `.secret`, because config.json is ordinary vault state and the secret is
61
+ * not. The client id, which is public, does live in config.json.
62
+ */
63
+ exports.GITHUB_SECRET_FILE = '.github-secret';
64
+ // The three places the flow talks to, overridable so the tests can stand a
65
+ // local stub in for github.com. Nothing else writes this.
66
+ let endpoints = {
67
+ authorize: 'https://github.com/login/oauth/authorize',
68
+ token: 'https://github.com/login/oauth/access_token',
69
+ api: 'https://api.github.com',
70
+ };
71
+ function overrideGithubEndpointsForTests(next) {
72
+ endpoints = next;
73
+ }
74
+ function readGithubSecret(root) {
75
+ try {
76
+ const secret = fs.readFileSync(path.join(root, exports.GITHUB_SECRET_FILE), 'utf8').trim();
77
+ return secret === '' ? null : secret;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function writeGithubSecret(root, secret) {
84
+ fs.writeFileSync(path.join(root, exports.GITHUB_SECRET_FILE), secret.trim() + '\n', { mode: 0o600 });
85
+ }
86
+ function clearGithubSecret(root) {
87
+ fs.rmSync(path.join(root, exports.GITHUB_SECRET_FILE), { force: true });
88
+ }
89
+ /** Whether sign-in with GitHub can work here: a client id and a stored secret. */
90
+ function githubConfigured(root) {
91
+ return (0, config_1.loadConfig)(root).auth.githubClientId !== '' && readGithubSecret(root) !== null;
92
+ }
93
+ /** Where the sign-in redirect sends the browser. No scopes are requested: the
94
+ * callback asks /user for the id and login, which a scopeless token can read. */
95
+ function githubAuthorizeUrl(clientId, redirectUri, state) {
96
+ const u = new URL(endpoints.authorize);
97
+ u.searchParams.set('client_id', clientId);
98
+ u.searchParams.set('redirect_uri', redirectUri);
99
+ u.searchParams.set('state', state);
100
+ return u.toString();
101
+ }
102
+ const FETCH_TIMEOUT_MS = 10000;
103
+ // GitHub's API refuses requests that carry no User-Agent.
104
+ const USER_AGENT = 'mochi-forge';
105
+ /** Trade the callback's code for an access token. Null on any refusal or
106
+ * network failure; the caller has one message for all of them, since the
107
+ * person at the browser can do the same thing about each: try again. */
108
+ async function exchangeGithubCode(opts) {
109
+ try {
110
+ const res = await fetch(endpoints.token, {
111
+ method: 'POST',
112
+ headers: {
113
+ accept: 'application/json',
114
+ 'content-type': 'application/x-www-form-urlencoded',
115
+ 'user-agent': USER_AGENT,
116
+ },
117
+ body: new URLSearchParams({
118
+ client_id: opts.clientId,
119
+ client_secret: opts.clientSecret,
120
+ code: opts.code,
121
+ redirect_uri: opts.redirectUri,
122
+ }),
123
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
124
+ });
125
+ if (!res.ok)
126
+ return null;
127
+ const body = (await res.json());
128
+ return typeof body.access_token === 'string' && body.access_token !== '' ? body.access_token : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ /** Who the access token belongs to. The token's only use; it is not kept. */
135
+ async function fetchGithubUser(accessToken) {
136
+ try {
137
+ const res = await fetch(`${endpoints.api}/user`, {
138
+ headers: {
139
+ accept: 'application/vnd.github+json',
140
+ authorization: `Bearer ${accessToken}`,
141
+ 'user-agent': USER_AGENT,
142
+ },
143
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
144
+ });
145
+ if (!res.ok)
146
+ return null;
147
+ return asGithubUser(await res.json());
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ /** A GitHub login: alphanumeric with interior hyphens, at most 39 characters.
154
+ * Checked before a lookup so a stray path or an @handle is refused with a
155
+ * message rather than sent to the API inside a URL. */
156
+ function isPlausibleGithubLogin(login) {
157
+ return /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(login);
158
+ }
159
+ /**
160
+ * Resolve a login to its stable numeric id, over GitHub's public,
161
+ * unauthenticated API. Called once per approval an administrator adds, which
162
+ * sits far under that API's rate limit.
163
+ */
164
+ async function lookupGithubLogin(login) {
165
+ try {
166
+ const res = await fetch(`${endpoints.api}/users/${encodeURIComponent(login)}`, {
167
+ headers: { accept: 'application/vnd.github+json', 'user-agent': USER_AGENT },
168
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
169
+ });
170
+ if (!res.ok)
171
+ return null;
172
+ return asGithubUser(await res.json());
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ function asGithubUser(body) {
179
+ if (typeof body !== 'object' || body === null)
180
+ return null;
181
+ const rec = body;
182
+ if (typeof rec.id !== 'number' || typeof rec.login !== 'string')
183
+ return null;
184
+ return { id: Math.floor(rec.id), login: rec.login };
185
+ }
package/dist/icons.js CHANGED
@@ -43,6 +43,7 @@ const PATHS = {
43
43
  clock: '<circle cx="12" cy="12" r="9"/><path d="M12 6.8V12L15.6 14.1"/>',
44
44
  sync: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/><path d="M1.9 14.6 3.5 12 5.1 14.6"/>',
45
45
  kebab: `${DOT(5, 12)}${DOT(12, 12)}${DOT(19, 12)}`,
46
+ info: `<circle cx="12" cy="12" r="9"/>${DOT(12, 7.8, 1.2)}<path d="M12 11.2V16.6"/>`,
46
47
  // Repositories and their contents.
47
48
  repo: '<rect x="4" y="3" width="16" height="18" rx="2.5"/><path d="M8.5 3V21"/>',
48
49
  'repo-forked': '<circle cx="6" cy="5" r="2.5"/><circle cx="18" cy="5" r="2.5"/><circle cx="12" cy="19" r="2.5"/><path d="M6 7.5v1.5a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7.5M12 12v4.5"/>',
@@ -55,6 +56,9 @@ const PATHS = {
55
56
  package: '<path d="M3.2 7.2 12 2.4l8.8 4.8v9.6L12 21.6l-8.8-4.8Z"/><path d="M3.2 7.2 12 12l8.8-4.8M12 12v9.6"/>',
56
57
  tag: '<path d="M13.6 3.6A2 2 0 0 0 12.2 3H5a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l7.2 7.2a2 2 0 0 0 2.8 0l7.2-7.2a2 2 0 0 0 0-2.8Z"/><circle cx="7.6" cy="7.6" r="1.4"/>',
57
58
  law: '<path d="M12 4V21M8 21h8M4.5 7.5 12 6l7.5 1.5"/><path d="M4.5 7.5 2 13a2.5 2.5 0 0 0 5 0Z"/><path d="M19.5 7.5 17 13a2.5 2.5 0 0 0 5 0Z"/>',
59
+ lock: `<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8.5 11V7.5a3.5 3.5 0 0 1 7 0V11"/>${DOT(12, 15.5)}`,
60
+ eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>',
61
+ 'eye-off': '<path d="M2.5 12S6 5.5 12 5.5c1.6 0 3 .4 4.3 1.1M20 8.5c.9 1.9 1.5 3.5 1.5 3.5S18 18.5 12 18.5c-1.6 0-3-.4-4.3-1.1"/><path d="M9.9 14.1a2.8 2.8 0 0 1 4-4"/><path d="M4 20 20 4"/>',
58
62
  // The git graph.
59
63
  'git-commit': '<circle cx="12" cy="12" r="3.6"/><path d="M2.5 12h5.9M15.6 12h5.9"/>',
60
64
  'git-branch': '<circle cx="6" cy="18.5" r="2.5"/><circle cx="18" cy="6" r="2.5"/><path d="M6 16V3M18 8.5a9.5 9.5 0 0 1-9.5 9.5H8"/>',
@@ -70,6 +74,16 @@ const PATHS = {
70
74
  filter: '<path d="M3.5 6.5h17M6.5 12h11M10 17.5h4"/>',
71
75
  search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.2 15.2 20.5 20.5"/>',
72
76
  link: '<path d="M9.5 14.5 14.5 9.5"/><path d="M10.5 6.6 12.3 4.8a4.5 4.5 0 0 1 6.4 6.4l-1.8 1.8"/><path d="M13.5 17.4 11.7 19.2a4.5 4.5 0 0 1-6.4-6.4l1.8-1.8"/>',
77
+ // The markdown toolbar. Letterforms and list marks, drawn in the same
78
+ // monoline stroke as everything else rather than borrowed from a text face.
79
+ heading: '<path d="M5.5 5v14M18.5 5v14M5.5 12h13"/>',
80
+ bold: '<path d="M7.5 5.5V18.5"/><path d="M7.5 5.5h5.2a3.25 3.25 0 0 1 0 6.5H7.5"/><path d="M7.5 12h6.2a3.25 3.25 0 0 1 0 6.5H7.5"/>',
81
+ italic: '<path d="M10.5 5.5h7M6.5 18.5h7M14 5.5 10 18.5"/>',
82
+ strikethrough: '<path d="M4 12h16"/><path d="M16.6 7.4C16 6 14.2 5 12 5 9.5 5 7.6 6.3 7.6 8.2c0 1.4 1 2.3 2.9 2.9"/><path d="M7.4 16.6C8 18 9.8 19 12 19c2.5 0 4.4-1.3 4.4-3.2 0-.6-.2-1.1-.5-1.5"/>',
83
+ quote: '<path d="M4.5 5.5v13"/><path d="M9.5 7.5h11M9.5 12h8M9.5 16.5h11"/>',
84
+ 'list-unordered': `${DOT(4.5, 6)}${DOT(4.5, 12)}${DOT(4.5, 18)}<path d="M9.5 6h11M9.5 12h11M9.5 18h11"/>`,
85
+ 'list-ordered': '<path d="M4.2 5.6 6.1 4.2V9.5"/><path d="M4.2 15.2a1.9 1.9 0 0 1 3.7.6c0 1.4-3.7 2.2-3.7 4h3.9"/><path d="M11 6.5h9.5M11 17.5h9.5"/>',
86
+ tasklist: '<rect x="3.5" y="3.5" width="7" height="7" rx="1.8"/><path d="M5.7 7.2 6.9 8.4 8.8 6"/><path d="M14 7h6.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.8"/><path d="M14 17h6.5"/>',
73
87
  // People.
74
88
  person: '<circle cx="12" cy="8" r="3.6"/><path d="M4.5 20.5a7.5 7.5 0 0 1 15 0"/>',
75
89
  people: '<circle cx="8.6" cy="8" r="3.2"/><circle cx="17" cy="8.8" r="2.4"/><path d="M2.6 19.5a6 6 0 0 1 12 0"/><path d="M16.2 14.2a5.6 5.6 0 0 1 5.2 5.3"/>',