@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.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +138 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +192 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/api/backup.js
CHANGED
|
@@ -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
|
-
/**
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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,
|
|
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) => !
|
|
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: ${[...
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
const release = await
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
315
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
287
316
|
}
|
|
288
317
|
catch {
|
|
289
|
-
|
|
290
|
-
// never reaches this; report the honest "unknown" if it somehow does.
|
|
291
|
-
lfs = 'unknown';
|
|
318
|
+
return true;
|
|
292
319
|
}
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
299
|
-
if (
|
|
300
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
}
|
package/dist/api/contents.js
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
}
|