@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
@@ -33,17 +33,22 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SECRET_FILES = exports.ROOT_FILES = void 0;
37
+ exports.registerBackupRoutes = registerBackupRoutes;
36
38
  exports.registerBackupApi = registerBackupApi;
37
39
  const crypto = __importStar(require("crypto"));
38
40
  const fs = __importStar(require("fs"));
39
41
  const path = __importStar(require("path"));
42
+ const egress_1 = require("../egress");
40
43
  const git_1 = require("../git");
44
+ const githubauth_1 = require("../githubauth");
41
45
  const limit_1 = require("../limit");
42
46
  const layout_1 = require("../layout");
43
47
  const ops_1 = require("../ops");
44
48
  const perms_1 = require("../perms");
45
49
  const scan_1 = require("../scan");
46
50
  const lfsstore_1 = require("../lfsstore");
51
+ const naming_1 = require("../naming");
47
52
  const auth_1 = require("./auth");
48
53
  // The two routes a backup needs, and nothing else.
49
54
  //
@@ -63,20 +68,40 @@ const auth_1 = require("./auth");
63
68
  //
64
69
  // See docs/backup.md for the client's half and for what a backup does not
65
70
  // promise.
66
- /** The state files at the vault root. Nothing else there belongs to a vault. */
67
- const ROOT_FILES = ['vault.json', 'config.json', 'runners.json', 'redirects.json', '.secret'];
71
+ /**
72
+ * The state files at the vault root. Nothing else there belongs to a vault.
73
+ *
74
+ * Named from the modules that write them where a module exports the name,
75
+ * so that a file the server starts writing under a new name is a compile
76
+ * error here rather than a file the backup quietly leaves behind. That is how
77
+ * `.github-secret` was lost for a while: config.json kept the client id, so a
78
+ * restored vault offered GitHub sign-in and failed every attempt at the
79
+ * token exchange, and `mochi backup verify` had nothing to say about it.
80
+ */
81
+ exports.ROOT_FILES = [
82
+ 'vault.json',
83
+ 'config.json',
84
+ 'runners.json',
85
+ 'redirects.json',
86
+ 'domains.json',
87
+ egress_1.EGRESS_FILE,
88
+ '.secret',
89
+ githubauth_1.GITHUB_SECRET_FILE,
90
+ ];
68
91
  /** Which of those `--no-secrets` leaves out. config.json holds no credential. */
69
- const SECRET_FILES = new Set(['vault.json', 'runners.json', '.secret']);
92
+ exports.SECRET_FILES = new Set(['vault.json', 'runners.json', '.secret', githubauth_1.GITHUB_SECRET_FILE]);
70
93
  /**
71
94
  * The files inside a bare repository that git's own transport leaves behind. A
72
95
  * mirror clone carries objects and refs; it writes its own default description
73
96
  * and its own config, so a backup that relied on it alone would lose every
74
97
  * repository's description, its `mochi.forkedFrom`, and the `receive.*`
75
98
  * settings a repository is created with. mochi.json is the worst of the
76
- * three to lose: it holds the private flag and the collaborators, so a
99
+ * set to lose: it holds the private flag and the collaborators, so a
77
100
  * restore without it would serve every private repository as public.
101
+ * site.json is the site's enabled switch, source, and label, which a restore
102
+ * without it would leave every site dark.
78
103
  */
79
- const REPO_FILES = ['description', 'config', 'mochi.json'];
104
+ const REPO_FILES = ['description', 'config', 'mochi.json', 'site.json'];
80
105
  /** What a caller may ask to have left out, as `?exclude=runs,sites`. */
81
106
  const EXCLUDABLE = new Set(['runs', 'sites', 'lfs', 'secrets']);
82
107
  /** At most this many paths in one fetch, so a request cannot become a whole vault. */
@@ -97,22 +122,16 @@ function isTempName(name) {
97
122
  * file: this runs on a 512mb machine, and an LFS object on the volume can be
98
123
  * gigabytes, so reading a whole file to hash it would be the one place a backup
99
124
  * could take the vault down.
125
+ *
126
+ * Streamed rather than read synchronously, because the other thing a backup
127
+ * could do to the vault is stall it: the client asks for hashes by default,
128
+ * and a synchronous loop over gigabytes of LFS objects held the event loop,
129
+ * and so every other request, for as long as the hashing took.
100
130
  */
101
- function sha256File(file) {
131
+ async function sha256File(file) {
102
132
  const h = crypto.createHash('sha256');
103
- const fd = fs.openSync(file, 'r');
104
- try {
105
- const buf = Buffer.allocUnsafe(1 << 16);
106
- for (;;) {
107
- const n = fs.readSync(fd, buf, 0, buf.length, null);
108
- if (n === 0)
109
- break;
110
- h.update(buf.subarray(0, n));
111
- }
112
- }
113
- finally {
114
- fs.closeSync(fd);
115
- }
133
+ for await (const chunk of fs.createReadStream(file, { highWaterMark: 1 << 16 }))
134
+ h.update(chunk);
116
135
  return h.digest('hex');
117
136
  }
118
137
  /**
@@ -157,7 +176,7 @@ function vaultPath(root, p) {
157
176
  return path.join(root, ...segments);
158
177
  }
159
178
  /** One `kind:"file"` line, or null for something that is not a file of the vault. */
160
- function fileLine(root, abs, opts) {
179
+ async function fileLine(root, abs, opts) {
161
180
  let st;
162
181
  try {
163
182
  st = fs.lstatSync(abs);
@@ -179,7 +198,7 @@ function fileLine(root, abs, opts) {
179
198
  };
180
199
  if (opts.hash) {
181
200
  try {
182
- line.sha256 = sha256File(abs);
201
+ line.sha256 = await sha256File(abs);
183
202
  }
184
203
  catch {
185
204
  return null;
@@ -226,11 +245,17 @@ async function refsDigest(dir) {
226
245
  }
227
246
  return crypto.createHash('sha256').update(out).update(`HEAD ${head}\n`).digest('hex');
228
247
  }
229
- function registerBackupApi(app, root, limiter, gates) {
230
- // The manifest necessarily names vault.json and .secret, and a fetch will
231
- // hand over their contents, so nothing narrower than site admin is enough.
232
- // A restricted (token-scoped) token is refused by isSiteAdmin whatever its
233
- // user's standing, which is the behaviour wanted here.
248
+ /**
249
+ * The two backup routes, over any layout. See the top of this file for the
250
+ * protocol; everything here is the same whichever application is serving it.
251
+ */
252
+ function registerBackupRoutes(app, root, limiter, layout) {
253
+ const noun = () => naming_1.naming.rootNoun;
254
+ const enter = layout.enter ?? (async () => () => undefined);
255
+ // The manifest necessarily names the identity file and .secret, and a fetch
256
+ // will hand over their contents, so nothing narrower than site admin is
257
+ // enough. A restricted (token-scoped) token is refused by isSiteAdmin
258
+ // whatever its user's standing, which is the behaviour wanted here.
234
259
  function requireVaultAdmin(req, res) {
235
260
  const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
236
261
  if (!auth)
@@ -243,7 +268,7 @@ function registerBackupApi(app, root, limiter, gates) {
243
268
  }
244
269
  function sendBusy(res) {
245
270
  res.setHeader('Retry-After', String(limit_1.BUSY_RETRY_SECONDS));
246
- (0, auth_1.apiError)(res, 503, 'the vault is busy; try again shortly');
271
+ (0, auth_1.apiError)(res, 503, `the ${noun()} is busy; try again shortly`);
247
272
  }
248
273
  function exclusions(req, res) {
249
274
  const raw = typeof req.query.exclude === 'string' ? req.query.exclude : '';
@@ -251,9 +276,9 @@ function registerBackupApi(app, root, limiter, gates) {
251
276
  .split(',')
252
277
  .map((s) => s.trim())
253
278
  .filter((s) => s.length > 0);
254
- const unknown = names.filter((n) => !EXCLUDABLE.has(n));
279
+ const unknown = names.filter((n) => !layout.excludable.has(n));
255
280
  if (unknown.length) {
256
- (0, auth_1.apiError)(res, 400, `unknown exclusion${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}; one of: ${[...EXCLUDABLE].join(', ')}`);
281
+ (0, auth_1.apiError)(res, 400, `unknown exclusion${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}; one of: ${[...layout.excludable].join(', ')}`);
257
282
  return null;
258
283
  }
259
284
  return new Set(names);
@@ -265,9 +290,9 @@ function registerBackupApi(app, root, limiter, gates) {
265
290
  if (!exclude)
266
291
  return;
267
292
  const opts = { hash: req.query.hash === '1', exclude };
268
- // The same gate a file listing and a source archive hold, so that a backup
269
- // in progress cannot crowd out a push.
270
- const release = await gates.tree.enter();
293
+ // Gated, where the application has a gate, so that a backup in progress
294
+ // cannot crowd out the work the gate protects.
295
+ const release = await enter();
271
296
  if (!release) {
272
297
  sendBusy(res);
273
298
  return;
@@ -276,173 +301,60 @@ function registerBackupApi(app, root, limiter, gates) {
276
301
  // A manifest is a walk of a live tree and is never worth a cache.
277
302
  res.set('Cache-Control', 'no-store');
278
303
  const counts = { files: 0, bytes: 0, repos: 0 };
279
- try {
280
- // Which LFS backend is live is decided from the environment, so the
281
- // client cannot infer it from the vault's files. A vault using a bucket
282
- // has objects that are not in the vault at all, and a backup that did not
283
- // say so would look complete while missing them.
284
- let lfs = 'volume';
304
+ const emitFile = async (abs) => {
305
+ const line = await fileLine(root, abs, opts);
306
+ if (!line)
307
+ return true;
308
+ counts.files++;
309
+ counts.bytes += JSON.parse(line).size;
310
+ return send(res, line + '\n');
311
+ };
312
+ const walkTree = async (dir, skip) => {
313
+ let entries;
285
314
  try {
286
- lfs = (0, lfsstore_1.createLfsStore)(root).store.kind === 's3' ? 'bucket' : 'volume';
315
+ entries = fs.readdirSync(dir, { withFileTypes: true });
287
316
  }
288
317
  catch {
289
- // A partially configured bucket throws at startup, so a serving vault
290
- // never reaches this; report the honest "unknown" if it somehow does.
291
- lfs = 'unknown';
318
+ return true;
292
319
  }
293
- if (!(await send(res, JSON.stringify({ kind: 'vault', lfs, excluded: [...exclude] }) + '\n')))
294
- return;
295
- for (const name of ROOT_FILES) {
296
- if (exclude.has('secrets') && SECRET_FILES.has(name))
320
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
321
+ if (e.isSymbolicLink() || isTempName(e.name))
297
322
  continue;
298
- const line = fileLine(root, path.join(root, name), opts);
299
- if (!line)
300
- continue;
301
- counts.files++;
302
- counts.bytes += JSON.parse(line).size;
303
- if (!(await send(res, line + '\n')))
304
- return;
305
- }
306
- const walkFiles = async (dir) => {
307
- let entries;
308
- try {
309
- entries = fs.readdirSync(dir, { withFileTypes: true });
310
- }
311
- catch {
312
- return true;
313
- }
314
- for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
315
- if (e.isSymbolicLink() || isTempName(e.name))
316
- continue;
317
- const abs = path.join(dir, e.name);
318
- if (e.isDirectory()) {
319
- if (!(await walkFiles(abs)))
320
- return false;
323
+ const abs = path.join(dir, e.name);
324
+ if (e.isDirectory()) {
325
+ if (skip?.(e.name, abs))
321
326
  continue;
322
- }
323
- const line = fileLine(root, abs, opts);
324
- if (!line)
325
- continue;
326
- counts.files++;
327
- counts.bytes += JSON.parse(line).size;
328
- if (!(await send(res, line + '\n')))
327
+ if (!(await walkTree(abs, skip)))
329
328
  return false;
330
- }
331
- return true;
332
- };
333
- let collections;
334
- try {
335
- collections = fs.readdirSync((0, layout_1.collectionsDir)(root), { withFileTypes: true });
336
- }
337
- catch {
338
- collections = [];
339
- }
340
- for (const c of collections.sort((a, b) => a.name.localeCompare(b.name))) {
341
- if (!c.isDirectory() || c.isSymbolicLink() || !(0, scan_1.isValidName)(c.name))
342
329
  continue;
343
- // Whatever the collection keeps of its own, beside its repositories.
344
- // There is nothing there today; it is walked as ordinary files so that
345
- // the first thing put there is backed up without this being revisited.
346
- let ownEntries;
347
- try {
348
- ownEntries = fs.readdirSync((0, layout_1.collectionDir)(root, c.name), { withFileTypes: true });
349
- }
350
- catch {
351
- ownEntries = [];
352
330
  }
353
- for (const e of ownEntries.sort((a, b) => a.name.localeCompare(b.name))) {
354
- if (e.name === layout_1.REPOS_DIR || e.isSymbolicLink() || isTempName(e.name))
355
- continue;
356
- const abs = path.join((0, layout_1.collectionDir)(root, c.name), e.name);
357
- if (e.isDirectory()) {
358
- if (!(await walkFiles(abs)))
359
- return;
360
- continue;
361
- }
362
- const line = fileLine(root, abs, opts);
363
- if (!line)
364
- continue;
365
- counts.files++;
366
- counts.bytes += JSON.parse(line).size;
367
- if (!(await send(res, line + '\n')))
368
- return;
369
- }
370
- const repos = (0, layout_1.reposDir)(root, c.name);
371
- let entries;
372
- try {
373
- entries = fs.readdirSync(repos, { withFileTypes: true });
374
- }
375
- catch {
331
+ if (!(await emitFile(abs)))
332
+ return false;
333
+ }
334
+ return true;
335
+ };
336
+ const writer = {
337
+ file: emitFile,
338
+ tree: walkTree,
339
+ line: async (entry) => {
340
+ if (entry.kind === 'repo')
341
+ counts.repos++;
342
+ return send(res, JSON.stringify(entry) + '\n');
343
+ },
344
+ };
345
+ try {
346
+ // The header's kind is the protocol's name for the root directory, and
347
+ // stays "vault" whatever the application calls it: a client reads it.
348
+ if (!(await send(res, JSON.stringify({ kind: 'vault', ...layout.header(), excluded: [...exclude] }) + '\n')))
349
+ return;
350
+ for (const name of layout.rootFiles) {
351
+ if (exclude.has('secrets') && layout.secretFiles.has(name))
376
352
  continue;
377
- }
378
- for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
379
- if (e.isSymbolicLink() || isTempName(e.name))
380
- continue;
381
- const abs = path.join(repos, e.name);
382
- const rel = `${layout_1.COLLECTIONS_DIR}/${c.name}/${layout_1.REPOS_DIR}/${e.name}`;
383
- if (!e.isDirectory()) {
384
- const line = fileLine(root, abs, opts);
385
- if (!line)
386
- continue;
387
- counts.files++;
388
- counts.bytes += JSON.parse(line).size;
389
- if (!(await send(res, line + '\n')))
390
- return;
391
- continue;
392
- }
393
- // A repository's contents are not enumerated: git is their
394
- // transport, and listing a hundred thousand loose objects as files
395
- // would be both enormous and the wrong way to move them.
396
- if ((0, scan_1.isValidName)(e.name) && (0, scan_1.isBareRepo)(abs)) {
397
- let refs;
398
- try {
399
- refs = await refsDigest(abs);
400
- }
401
- catch {
402
- // A directory that looks like a repository but cannot be read is
403
- // reported as nothing rather than failing the whole manifest.
404
- continue;
405
- }
406
- counts.repos++;
407
- const line = JSON.stringify({
408
- kind: 'repo',
409
- path: rel,
410
- collection: c.name,
411
- repo: e.name.replace(/\.git$/, ''),
412
- refs,
413
- packed: await repoBytes(abs),
414
- });
415
- if (!(await send(res, line + '\n')))
416
- return;
417
- // A few files inside the repository are named all the same,
418
- // because a mirror clone does not carry them and they are not git
419
- // data: the description, which every listing shows; the config,
420
- // which holds the fork parent and the receive protections a
421
- // repository was created with; and mochi.json, which holds
422
- // the private flag and the collaborators. Restoring a vault whose
423
- // private repositories had come back public would be far worse
424
- // than a poor restore.
425
- for (const inside of REPO_FILES) {
426
- const fileEntry = fileLine(root, path.join(abs, inside), opts);
427
- if (!fileEntry)
428
- continue;
429
- counts.files++;
430
- counts.bytes += JSON.parse(fileEntry).size;
431
- if (!(await send(res, fileEntry + '\n')))
432
- return;
433
- }
434
- continue;
435
- }
436
- if (exclude.has('runs') && e.name.endsWith('.runs'))
437
- continue;
438
- if (exclude.has('sites') && e.name.endsWith('.site'))
439
- continue;
440
- if (exclude.has('lfs') && e.name.endsWith('.lfs'))
441
- continue;
442
- if (!(await walkFiles(abs)))
443
- return;
444
- }
353
+ if (!(await emitFile(path.join(root, name))))
354
+ return;
445
355
  }
356
+ if (!(await layout.walk(writer, exclude)))
357
+ return;
446
358
  await send(res, JSON.stringify({ kind: 'end', ...counts }) + '\n');
447
359
  res.end();
448
360
  }
@@ -472,7 +384,7 @@ function registerBackupApi(app, root, limiter, gates) {
472
384
  const body = (req.body ?? {});
473
385
  const paths = body.paths;
474
386
  if (!Array.isArray(paths) || paths.length === 0) {
475
- (0, auth_1.apiError)(res, 400, '"paths" must be a non-empty list of vault-relative paths');
387
+ (0, auth_1.apiError)(res, 400, `"paths" must be a non-empty list of ${noun()}-relative paths`);
476
388
  return;
477
389
  }
478
390
  if (paths.length > MAX_FETCH_PATHS) {
@@ -483,7 +395,7 @@ function registerBackupApi(app, root, limiter, gates) {
483
395
  for (const p of paths) {
484
396
  const abs = vaultPath(root, p);
485
397
  if (!abs) {
486
- (0, auth_1.apiError)(res, 400, `not a path inside the vault: ${typeof p === 'string' ? p : typeof p}`);
398
+ (0, auth_1.apiError)(res, 400, `not a path inside the ${noun()}: ${typeof p === 'string' ? p : typeof p}`);
487
399
  return;
488
400
  }
489
401
  absolute.push(abs);
@@ -493,7 +405,7 @@ function registerBackupApi(app, root, limiter, gates) {
493
405
  rootReal = fs.realpathSync(root);
494
406
  }
495
407
  catch {
496
- (0, auth_1.apiError)(res, 500, 'the vault directory could not be read');
408
+ (0, auth_1.apiError)(res, 500, `the ${noun()} directory could not be read`);
497
409
  return;
498
410
  }
499
411
  // Sized before anything is sent, so that a request over the cap is a 400
@@ -512,7 +424,7 @@ function registerBackupApi(app, root, limiter, gates) {
512
424
  (0, auth_1.apiError)(res, 400, `the paths named come to ${total} bytes, over the ${MAX_FETCH_BYTES} byte limit for one request; ask for fewer`);
513
425
  return;
514
426
  }
515
- const release = await gates.tree.enter();
427
+ const release = await enter();
516
428
  if (!release) {
517
429
  sendBusy(res);
518
430
  return;
@@ -596,3 +508,126 @@ function registerBackupApi(app, root, limiter, gates) {
596
508
  }
597
509
  });
598
510
  }
511
+ /** Mochi's layout: the vault's state files, then each collection and its repositories. */
512
+ function vaultLayout(root, gates) {
513
+ return {
514
+ rootFiles: exports.ROOT_FILES,
515
+ secretFiles: exports.SECRET_FILES,
516
+ excludable: EXCLUDABLE,
517
+ enter: () => gates.tree.enter(),
518
+ header() {
519
+ // Which LFS backend is live is decided from the environment, so the
520
+ // client cannot infer it from the vault's files. A vault using a bucket
521
+ // has objects that are not in the vault at all, and a backup that did not
522
+ // say so would look complete while missing them.
523
+ let lfs = 'volume';
524
+ try {
525
+ lfs = (0, lfsstore_1.createLfsStore)(root).store.kind === 's3' ? 'bucket' : 'volume';
526
+ }
527
+ catch {
528
+ // A partially configured bucket throws at startup, so a serving vault
529
+ // never reaches this; report the honest "unknown" if it somehow does.
530
+ lfs = 'unknown';
531
+ }
532
+ return { lfs };
533
+ },
534
+ async walk(w, exclude) {
535
+ let collections;
536
+ try {
537
+ collections = fs.readdirSync((0, layout_1.collectionsDir)(root), { withFileTypes: true });
538
+ }
539
+ catch {
540
+ collections = [];
541
+ }
542
+ for (const c of collections.sort((a, b) => a.name.localeCompare(b.name))) {
543
+ if (!c.isDirectory() || c.isSymbolicLink() || !(0, scan_1.isValidName)(c.name))
544
+ continue;
545
+ // Whatever the collection keeps of its own, beside its repositories.
546
+ // There is nothing there today; it is walked as ordinary files so that
547
+ // the first thing put there is backed up without this being revisited.
548
+ let ownEntries;
549
+ try {
550
+ ownEntries = fs.readdirSync((0, layout_1.collectionDir)(root, c.name), { withFileTypes: true });
551
+ }
552
+ catch {
553
+ ownEntries = [];
554
+ }
555
+ for (const e of ownEntries.sort((a, b) => a.name.localeCompare(b.name))) {
556
+ if (e.name === layout_1.REPOS_DIR || e.isSymbolicLink() || isTempName(e.name))
557
+ continue;
558
+ const abs = path.join((0, layout_1.collectionDir)(root, c.name), e.name);
559
+ if (!(await (e.isDirectory() ? w.tree(abs) : w.file(abs))))
560
+ return false;
561
+ }
562
+ const repos = (0, layout_1.reposDir)(root, c.name);
563
+ let entries;
564
+ try {
565
+ entries = fs.readdirSync(repos, { withFileTypes: true });
566
+ }
567
+ catch {
568
+ continue;
569
+ }
570
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
571
+ if (e.isSymbolicLink() || isTempName(e.name))
572
+ continue;
573
+ const abs = path.join(repos, e.name);
574
+ const rel = `${layout_1.COLLECTIONS_DIR}/${c.name}/${layout_1.REPOS_DIR}/${e.name}`;
575
+ if (!e.isDirectory()) {
576
+ if (!(await w.file(abs)))
577
+ return false;
578
+ continue;
579
+ }
580
+ // A repository's contents are not enumerated: git is their
581
+ // transport, and listing a hundred thousand loose objects as files
582
+ // would be both enormous and the wrong way to move them.
583
+ if ((0, scan_1.isValidName)(e.name) && (0, scan_1.isBareRepo)(abs)) {
584
+ let refs;
585
+ try {
586
+ refs = await refsDigest(abs);
587
+ }
588
+ catch {
589
+ // A directory that looks like a repository but cannot be read is
590
+ // reported as nothing rather than failing the whole manifest.
591
+ continue;
592
+ }
593
+ const ok = await w.line({
594
+ kind: 'repo',
595
+ path: rel,
596
+ collection: c.name,
597
+ repo: e.name.replace(/\.git$/, ''),
598
+ refs,
599
+ packed: await repoBytes(abs),
600
+ });
601
+ if (!ok)
602
+ return false;
603
+ // A few files inside the repository are named all the same,
604
+ // because a mirror clone does not carry them and they are not git
605
+ // data: the description, which every listing shows; the config,
606
+ // which holds the fork parent and the receive protections a
607
+ // repository was created with; and mochi.json, which holds
608
+ // the private flag and the collaborators. Restoring a vault whose
609
+ // private repositories had come back public would be far worse
610
+ // than a poor restore.
611
+ for (const inside of REPO_FILES) {
612
+ if (!(await w.file(path.join(abs, inside))))
613
+ return false;
614
+ }
615
+ continue;
616
+ }
617
+ if (exclude.has('runs') && e.name.endsWith('.runs'))
618
+ continue;
619
+ if (exclude.has('sites') && e.name.endsWith('.site'))
620
+ continue;
621
+ if (exclude.has('lfs') && e.name.endsWith('.lfs'))
622
+ continue;
623
+ if (!(await w.tree(abs)))
624
+ return false;
625
+ }
626
+ }
627
+ return true;
628
+ },
629
+ };
630
+ }
631
+ function registerBackupApi(app, root, limiter, gates) {
632
+ registerBackupRoutes(app, root, limiter, vaultLayout(root, gates));
633
+ }
@@ -35,6 +35,8 @@ const MAX_COMMITS = 250;
35
35
  const MAX_LISTED_PATHS = 20000;
36
36
  /** The same ceiling the editor applies, so that the two interfaces agree on what is too big. */
37
37
  const MAX_FILE_BYTES = 1024 * 1024;
38
+ /** Raw files over this size are streamed from git rather than read into memory first. */
39
+ const STREAM_ABOVE = 8 * 1024 * 1024;
38
40
  function registerContentsApi(app, root, limiter, gates) {
39
41
  // A directory listing. ?commits=1 adds the last commit per entry, which costs
40
42
  // one git log per entry; it is off by default because an API caller asking for
@@ -92,12 +94,20 @@ function registerContentsApi(app, root, limiter, gates) {
92
94
  (0, auth_1.apiError)(res, 400, 'that is not a usable path');
93
95
  return;
94
96
  }
97
+ // The size before the bytes: a file over the limit is refused from its
98
+ // size alone rather than read whole to be measured. A pointer is a few
99
+ // hundred bytes and is never the file refused here.
100
+ const info = await found.repo.blobInfo(ref, filePath);
101
+ if (!info) {
102
+ (0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
103
+ return;
104
+ }
105
+ if (info.size > MAX_FILE_BYTES) {
106
+ (0, auth_1.apiError)(res, 413, `${filePath} is larger than ${MAX_FILE_BYTES} bytes; fetch it from the raw route or a clone`);
107
+ return;
108
+ }
95
109
  let buf;
96
110
  try {
97
- if ((await found.repo.entryType(ref, filePath)) !== 'blob') {
98
- (0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
99
- return;
100
- }
101
111
  buf = await found.repo.catBlob(ref, filePath);
102
112
  }
103
113
  catch {
@@ -142,20 +152,29 @@ function registerContentsApi(app, root, limiter, gates) {
142
152
  (0, auth_1.apiError)(res, 400, 'ask for a file as /raw/<path>?ref=<ref>');
143
153
  return;
144
154
  }
155
+ const info = await found.repo.blobInfo(ref, filePath);
156
+ if (!info) {
157
+ (0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
158
+ return;
159
+ }
160
+ res.setHeader('Content-Security-Policy', 'sandbox');
161
+ res.setHeader('X-Content-Type-Options', 'nosniff');
162
+ // Large files go from git to the socket without being held here, as a
163
+ // download, since telling text from binary needs the bytes in hand.
164
+ if (info.size > STREAM_ABOVE) {
165
+ res.setHeader('Content-Length', String(info.size));
166
+ res.type('application/octet-stream');
167
+ await found.repo.streamBlob(ref, filePath, res);
168
+ return;
169
+ }
145
170
  let buf;
146
171
  try {
147
- if ((await found.repo.entryType(ref, filePath)) !== 'blob') {
148
- (0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
149
- return;
150
- }
151
172
  buf = await found.repo.catBlob(ref, filePath);
152
173
  }
153
174
  catch {
154
175
  (0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
155
176
  return;
156
177
  }
157
- res.setHeader('Content-Security-Policy', 'sandbox');
158
- res.setHeader('X-Content-Type-Options', 'nosniff');
159
178
  res.type((0, render_1.isBinary)(buf) ? 'application/octet-stream' : 'text/plain; charset=utf-8');
160
179
  res.send(buf);
161
180
  });
@@ -174,18 +193,29 @@ function registerContentsApi(app, root, limiter, gates) {
174
193
  return;
175
194
  }
176
195
  const limit = (0, auth_1.limitParam)(req.query.limit, 30, MAX_COMMITS);
177
- try {
178
- const commits = await found.repo.log(ref, 0, limit, filePath || undefined);
179
- res.json({ ref, commits });
180
- }
181
- catch {
196
+ // log() answers an unknown ref with an empty list rather than an error,
197
+ // because the web's tree page wants exactly that for an empty repository.
198
+ // The API is asked a question and should say no: a ref that does not
199
+ // resolve is 404, and an empty history is 200 with nothing in it.
200
+ if (!(await found.repo.resolve(ref))) {
182
201
  (0, auth_1.apiError)(res, 404, `ref ${ref} not found`);
202
+ return;
183
203
  }
204
+ const commits = await found.repo.log(ref, 0, limit, filePath || undefined);
205
+ res.json({ ref, commits });
184
206
  });
207
+ // A commit id, possibly abbreviated, as the web route takes it. Checked
208
+ // before it reaches git: an argument beginning with a dash is an option to
209
+ // git, and `--output=<path>` would write the listing into the vault.
210
+ const COMMIT_ID = /^[0-9a-f]{4,40}$/i;
185
211
  app.get('/api/repos/:collection/:repo/commits/:sha', async (req, res) => {
186
212
  const found = (0, auth_1.requireRepo)(root, limiter, req, res);
187
213
  if (!found)
188
214
  return;
215
+ if (!COMMIT_ID.test(req.params.sha)) {
216
+ (0, auth_1.apiError)(res, 404, `no commit ${req.params.sha}`);
217
+ return;
218
+ }
189
219
  const detail = await found.repo.commit(req.params.sha);
190
220
  if (!detail) {
191
221
  (0, auth_1.apiError)(res, 404, `no commit ${req.params.sha}`);
@@ -197,7 +227,7 @@ function registerContentsApi(app, root, limiter, gates) {
197
227
  const found = (0, auth_1.requireRepo)(root, limiter, req, res);
198
228
  if (!found)
199
229
  return;
200
- if (!(await found.repo.commit(req.params.sha))) {
230
+ if (!COMMIT_ID.test(req.params.sha) || !(await found.repo.commit(req.params.sha))) {
201
231
  (0, auth_1.apiError)(res, 404, `no commit ${req.params.sha}`);
202
232
  return;
203
233
  }