@magland/mochi 0.3.1 → 0.4.1
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 +166 -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 +193 -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/browse.js
CHANGED
|
@@ -35,11 +35,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.IMAGE_TYPES = void 0;
|
|
37
37
|
exports.registerBrowse = registerBrowse;
|
|
38
|
-
const crypto = __importStar(require("crypto"));
|
|
39
38
|
const fs = __importStar(require("fs"));
|
|
40
39
|
const git_1 = require("./git");
|
|
41
40
|
const languages_1 = require("./languages");
|
|
42
41
|
const markdown_1 = require("./markdown");
|
|
42
|
+
const agefile_1 = require("./agefile");
|
|
43
43
|
const pointer_1 = require("./pointer");
|
|
44
44
|
const profile_1 = require("./profile");
|
|
45
45
|
const render_1 = require("./render");
|
|
@@ -49,7 +49,9 @@ const diff_1 = require("./diff");
|
|
|
49
49
|
const layout_1 = require("./layout");
|
|
50
50
|
const perms_1 = require("./perms");
|
|
51
51
|
const scan_1 = require("./scan");
|
|
52
|
+
const topics_1 = require("./topics");
|
|
52
53
|
const session_1 = require("./session");
|
|
54
|
+
const sitesettings_1 = require("./sitesettings");
|
|
53
55
|
const site_1 = require("./site");
|
|
54
56
|
const vault_1 = require("./vault");
|
|
55
57
|
const views = __importStar(require("./views"));
|
|
@@ -57,6 +59,50 @@ const views_1 = require("./views");
|
|
|
57
59
|
const web_1 = require("./web");
|
|
58
60
|
const COMMITS_PER_PAGE = 35;
|
|
59
61
|
const MAX_RENDER_SIZE = 1024 * 1024;
|
|
62
|
+
/**
|
|
63
|
+
* Raw files over this size are streamed from git rather than read into
|
|
64
|
+
* memory first. Below it the bytes are read whole, which is what lets the
|
|
65
|
+
* route tell a pointer from a file and text from binary; above it nothing
|
|
66
|
+
* needs the bytes but the socket.
|
|
67
|
+
*/
|
|
68
|
+
const STREAM_ABOVE = 8 * 1024 * 1024;
|
|
69
|
+
/**
|
|
70
|
+
* The caching headers a raw file carries, and whether the request was
|
|
71
|
+
* answered with a 304 and needs nothing more.
|
|
72
|
+
*
|
|
73
|
+
* Reading a public repository is anonymous, so its raw files may be cached
|
|
74
|
+
* publicly; the question is only for how long. A full commit id can never
|
|
75
|
+
* come to name different bytes, so under one the answer is forever. Under a
|
|
76
|
+
* branch or tag it is "until it changes", which HTTP spells as revalidate
|
|
77
|
+
* every time: the ETag is the blob's own object id, which is a hash of the
|
|
78
|
+
* bytes, so an unchanged file costs a 304 and no body whichever commit now
|
|
79
|
+
* holds it. A private repository's bytes were served to one reader and must
|
|
80
|
+
* never come out of a shared cache for another.
|
|
81
|
+
*/
|
|
82
|
+
function rawCacheHeaders(req, res, ref, blobSha, isPrivate) {
|
|
83
|
+
const cacheScope = isPrivate ? 'private' : 'public';
|
|
84
|
+
if (/^[0-9a-f]{40}$/.test(ref)) {
|
|
85
|
+
res.setHeader('Cache-Control', `${cacheScope}, max-age=31536000, immutable`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
|
|
89
|
+
return notModified(req, res, `"${blobSha}"`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Set the validator and answer 304 if the request already holds it, saying
|
|
93
|
+
* so. Compared here rather than through req.fresh, which answered false to a
|
|
94
|
+
* matching If-None-Match on these routes for as long as they have set an
|
|
95
|
+
* ETag; the comparison is three lines, and this way it is known to run.
|
|
96
|
+
*/
|
|
97
|
+
function notModified(req, res, etag) {
|
|
98
|
+
res.setHeader('ETag', etag);
|
|
99
|
+
const presented = req.headers['if-none-match'];
|
|
100
|
+
if (presented && presented.split(',').some((t) => t.trim() === etag || t.trim() === '*')) {
|
|
101
|
+
res.status(304).end();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
60
106
|
const MAX_LISTED_COMMITS = 250;
|
|
61
107
|
const ARCHIVE_FORMATS = {
|
|
62
108
|
'tar.gz': { format: 'tar.gz', type: 'application/gzip' },
|
|
@@ -91,14 +137,16 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
91
137
|
// A repository with a site is linked straight to it from the listing,
|
|
92
138
|
// at its own origin where it has one, so a visitor scanning a
|
|
93
139
|
// collection reaches the published page without stopping at the
|
|
94
|
-
// repository first.
|
|
95
|
-
|
|
140
|
+
// repository first. Only when the site is enabled: files on disk with
|
|
141
|
+
// the switch off are not a site.
|
|
142
|
+
const hasSite = (0, sitesettings_1.siteSettings)(repo.dir).enabled && (0, scan_1.siteDir)(root, collection, name) !== null;
|
|
96
143
|
const origin = hasSite ? (0, site_1.siteHostUrl)(root, req, collection, name) : null;
|
|
97
144
|
const run = (0, runs_1.latestRun)(root, collection, name);
|
|
98
145
|
return {
|
|
99
146
|
collection,
|
|
100
147
|
name,
|
|
101
148
|
description: (0, scan_1.repoDescription)(repo.dir),
|
|
149
|
+
topics: (0, topics_1.repoTopics)(repo.dir),
|
|
102
150
|
isPrivate: (0, perms_1.repoIsPrivate)(repo.dir),
|
|
103
151
|
updated: await repo.lastUpdated(),
|
|
104
152
|
siteUrl: !hasSite
|
|
@@ -121,13 +169,47 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
121
169
|
function sortParam(req) {
|
|
122
170
|
return req.query.sort === 'name' ? 'name' : 'recent';
|
|
123
171
|
}
|
|
172
|
+
// The topic a listing is narrowed to. A value that is not a topic reads as
|
|
173
|
+
// no narrowing rather than an error, the way an unknown sort does.
|
|
174
|
+
function topicParam(req) {
|
|
175
|
+
const t = typeof req.query.topic === 'string' ? req.query.topic : '';
|
|
176
|
+
return (0, topics_1.isValidTopic)(t) ? t : '';
|
|
177
|
+
}
|
|
178
|
+
/** The filter a listing page hands its view: what was chosen, and what there is to choose. */
|
|
179
|
+
function topicFilter(cards, topic) {
|
|
180
|
+
return {
|
|
181
|
+
filtered: topic === '' ? cards : cards.filter((c) => (c.topics ?? []).includes(topic)),
|
|
182
|
+
filter: { current: topic, inUse: (0, topics_1.countTopics)(cards.map((c) => c.topics ?? [])) },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
124
185
|
app.get('/', (0, web_1.ah)(async (req, res) => {
|
|
125
186
|
const viewer = (0, session_1.getViewer)(req, root);
|
|
126
187
|
const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
|
|
127
188
|
// The counts beside collection names count what this viewer can see,
|
|
128
189
|
// for the same reason the cards do.
|
|
129
190
|
const collections = (0, scan_1.listCollections)(root).map((c, i) => ({ name: c.name, repoCount: cardsPer[i].length }));
|
|
130
|
-
|
|
191
|
+
const { filtered, filter } = topicFilter(cardsPer.flat(), topicParam(req));
|
|
192
|
+
res.type('html').send(views.homePage(root, collections, filtered, sortParam(req), viewer, filter));
|
|
193
|
+
}));
|
|
194
|
+
// The vault's topics, and one topic's repositories. Registered before the
|
|
195
|
+
// generic /:collection routes, and `topics` is a reserved name (see
|
|
196
|
+
// src/scan.ts), so no collection can sit where these answer.
|
|
197
|
+
app.get('/topics', (0, web_1.ah)(async (req, res) => {
|
|
198
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
199
|
+
const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
|
|
200
|
+
const counts = (0, topics_1.countTopics)(cardsPer.flat().map((c) => c.topics ?? []));
|
|
201
|
+
res.type('html').send(views.topicsIndexPage(counts, viewer));
|
|
202
|
+
}));
|
|
203
|
+
app.get('/topics/:topic', (0, web_1.ah)(async (req, res) => {
|
|
204
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
205
|
+
const topic = req.params.topic;
|
|
206
|
+
if (!(0, topics_1.isValidTopic)(topic)) {
|
|
207
|
+
(0, web_1.send404)(res, 'Not found', viewer);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
|
|
211
|
+
const repos = cardsPer.flat().filter((c) => (c.topics ?? []).includes(topic));
|
|
212
|
+
res.type('html').send(views.topicPage(topic, repos, sortParam(req), viewer));
|
|
131
213
|
}));
|
|
132
214
|
app.get('/about', (req, res) => {
|
|
133
215
|
res.type('html').send(views.aboutPage((0, web_1.baseUrlOf)(req), (0, session_1.getViewer)(req, root)));
|
|
@@ -142,7 +224,12 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
142
224
|
catch {
|
|
143
225
|
collectionIsDir = false;
|
|
144
226
|
}
|
|
145
|
-
|
|
227
|
+
// A vault user's namespace page exists as soon as the user does: the
|
|
228
|
+
// collection directory only appears with their first repository, and a
|
|
229
|
+
// profile that 404s until then would say the user does not exist.
|
|
230
|
+
const state = (0, vault_1.loadVault)(root);
|
|
231
|
+
const userRecord = (0, scan_1.isValidUserName)(collection) && state.status === 'ok' ? (state.vault.users[collection] ?? null) : null;
|
|
232
|
+
if (!(0, scan_1.isValidName)(collection) || (!collectionIsDir && !userRecord)) {
|
|
146
233
|
(0, web_1.send404)(res, `Collection ${collection} not found`, viewer);
|
|
147
234
|
return;
|
|
148
235
|
}
|
|
@@ -150,16 +237,29 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
150
237
|
// introduces the collection only to viewers who could read it.
|
|
151
238
|
const profileRepo = (0, scan_1.findRepo)(root, collection, '.mochi');
|
|
152
239
|
const profileVisible = profileRepo === null || (0, perms_1.repoRole)(root, viewer?.auth ?? null, profileRepo) !== null;
|
|
153
|
-
const [
|
|
154
|
-
repoCards(req, collection, viewer),
|
|
240
|
+
const [allCards, profile] = await Promise.all([
|
|
241
|
+
collectionIsDir ? repoCards(req, collection, viewer) : Promise.resolve([]),
|
|
155
242
|
profileVisible
|
|
156
243
|
? (0, profile_1.collectionProfile)(root, collection)
|
|
157
244
|
: Promise.resolve({ readme: null, addUrl: `/new?collection=${encodeURIComponent(collection)}` }),
|
|
158
245
|
]);
|
|
159
|
-
const canSettings = viewer !== null && (0, perms_1.canAdminCollection)(root, viewer.auth, collection);
|
|
246
|
+
const canSettings = collectionIsDir && viewer !== null && (0, perms_1.canAdminCollection)(root, viewer.auth, collection);
|
|
247
|
+
// What the page shows of the user behind the name. The links were held
|
|
248
|
+
// to http(s) where the profile was saved; the filter here keeps a
|
|
249
|
+
// hand-edited vault.json from linking the page anywhere else.
|
|
250
|
+
const owner = userRecord
|
|
251
|
+
? {
|
|
252
|
+
displayName: userRecord.profile?.name ?? null,
|
|
253
|
+
bio: userRecord.profile?.bio ?? null,
|
|
254
|
+
links: (userRecord.profile?.links ?? []).filter((l) => /^https?:\/\//i.test(l)),
|
|
255
|
+
siteAdmin: userRecord.siteAdmin === true,
|
|
256
|
+
isViewer: viewer !== null && viewer.auth.username === collection,
|
|
257
|
+
}
|
|
258
|
+
: null;
|
|
259
|
+
const { filtered, filter } = topicFilter(allCards, topicParam(req));
|
|
160
260
|
res
|
|
161
261
|
.type('html')
|
|
162
|
-
.send(views.collectionPage(collection,
|
|
262
|
+
.send(views.collectionPage(collection, filtered, sortParam(req), viewer, canSettings, profile, owner, filter));
|
|
163
263
|
}));
|
|
164
264
|
async function renderTree(req, res, loaded, ref, treePath) {
|
|
165
265
|
const viewer = (0, session_1.getViewer)(req, root);
|
|
@@ -212,6 +312,7 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
212
312
|
blobBase: `${base}/blob/${(0, views_1.encPath)(ref)}${dirSuffix}`,
|
|
213
313
|
issueBase: `${base}/issues`,
|
|
214
314
|
commitBase: `${base}/commit`,
|
|
315
|
+
mentions: (name) => (0, vault_1.userExists)(root, name),
|
|
215
316
|
});
|
|
216
317
|
}
|
|
217
318
|
else {
|
|
@@ -226,10 +327,12 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
226
327
|
latest,
|
|
227
328
|
commitCount,
|
|
228
329
|
description: (0, scan_1.repoDescription)(loaded.repo.dir),
|
|
330
|
+
topics: (0, topics_1.repoTopics)(loaded.repo.dir),
|
|
229
331
|
contributors,
|
|
230
332
|
readmeHtml,
|
|
231
333
|
readmeName,
|
|
232
334
|
languages,
|
|
335
|
+
msg: typeof req.query.msg === 'string' ? req.query.msg : undefined,
|
|
233
336
|
}));
|
|
234
337
|
}
|
|
235
338
|
app.get('/:collection/:repo', (0, web_1.ah)(async (req, res) => {
|
|
@@ -282,9 +385,20 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
282
385
|
(0, web_1.send404)(res, `File ${filePath} not found at ${ref}`, viewer);
|
|
283
386
|
return;
|
|
284
387
|
}
|
|
285
|
-
const buf = await loaded.repo.catBlob(ref, filePath);
|
|
286
388
|
const ext = (filePath.split('.').pop() ?? '').toLowerCase();
|
|
287
389
|
const rawUrl = `${(0, views_1.repoUrl)(ctx)}/raw/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`;
|
|
390
|
+
// The size first, and the bytes only if they will be shown. A file over
|
|
391
|
+
// the render limit gets its card from the size alone: nothing below
|
|
392
|
+
// would render it, and reading it whole to learn that held a file the
|
|
393
|
+
// size of the machine in memory. An LFS pointer is a few hundred bytes
|
|
394
|
+
// and never reaches this branch.
|
|
395
|
+
const info = await loaded.repo.blobInfo(ref, filePath);
|
|
396
|
+
if (info && info.size > MAX_RENDER_SIZE) {
|
|
397
|
+
const kind = exports.IMAGE_TYPES[ext] ? 'image' : 'too-large';
|
|
398
|
+
res.type('html').send(views.blobPage(ctx, filePath, { kind, rawUrl, size: info.size }));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const buf = await loaded.repo.catBlob(ref, filePath);
|
|
288
402
|
// LFS pointer detection precedes every content branch: an LFS-tracked
|
|
289
403
|
// .md or .png must render as a download card, not as its pointer text.
|
|
290
404
|
// ?plain=1 falls through to the source view, as on GitHub, keeping the
|
|
@@ -311,6 +425,20 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
311
425
|
}, true));
|
|
312
426
|
return;
|
|
313
427
|
}
|
|
428
|
+
// An age ciphertext renders as a decrypt-in-the-browser card, whatever
|
|
429
|
+
// its framing: armored files are text and binary ones are not, and the
|
|
430
|
+
// card treats both alike. ?plain=1 falls through, so the armored source
|
|
431
|
+
// is inspectable the way a markdown file's is.
|
|
432
|
+
if ((0, agefile_1.isAgeFile)(filePath) && req.query.plain !== '1') {
|
|
433
|
+
res.type('html').send(views.blobPage(ctx, filePath, {
|
|
434
|
+
kind: 'age',
|
|
435
|
+
rawUrl,
|
|
436
|
+
size: buf.length,
|
|
437
|
+
editable: ctx.canPush && ctx.refIsBranch,
|
|
438
|
+
markdownInner: (0, markdown_1.isMarkdownFile)((0, agefile_1.ageInnerName)(filePath)),
|
|
439
|
+
}));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
314
442
|
if (exports.IMAGE_TYPES[ext]) {
|
|
315
443
|
res.type('html').send(views.blobPage(ctx, filePath, { kind: 'image', rawUrl, size: buf.length }));
|
|
316
444
|
return;
|
|
@@ -334,6 +462,7 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
334
462
|
blobBase: `${(0, views_1.repoUrl)(ctx)}/blob/${(0, views_1.encPath)(ref)}${dir}`,
|
|
335
463
|
issueBase: `${(0, views_1.repoUrl)(ctx)}/issues`,
|
|
336
464
|
commitBase: `${(0, views_1.repoUrl)(ctx)}/commit`,
|
|
465
|
+
mentions: ctx.hasUser,
|
|
337
466
|
});
|
|
338
467
|
res
|
|
339
468
|
.type('html')
|
|
@@ -361,12 +490,17 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
361
490
|
}
|
|
362
491
|
const ctx = await (0, web_1.makeCtx)(root, req, loaded, ref, viewer);
|
|
363
492
|
const blobUrl = `${(0, views_1.repoUrl)(ctx)}/blob/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`;
|
|
364
|
-
|
|
493
|
+
const info = await loaded.repo.blobInfo(ref, filePath);
|
|
494
|
+
if (!info) {
|
|
365
495
|
(0, web_1.send404)(res, `File ${filePath} not found at ${ref}`, viewer);
|
|
366
496
|
return;
|
|
367
497
|
}
|
|
498
|
+
if (info.size > MAX_RENDER_SIZE) {
|
|
499
|
+
res.redirect(blobUrl);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
368
502
|
const buf = await loaded.repo.catBlob(ref, filePath);
|
|
369
|
-
if ((0, render_1.isBinary)(buf)
|
|
503
|
+
if ((0, render_1.isBinary)(buf)) {
|
|
370
504
|
res.redirect(blobUrl);
|
|
371
505
|
return;
|
|
372
506
|
}
|
|
@@ -383,11 +517,28 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
383
517
|
(0, web_1.send404)(res);
|
|
384
518
|
return;
|
|
385
519
|
}
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
520
|
+
const info = await loaded.repo.blobInfo(ref, filePath);
|
|
521
|
+
if (!info) {
|
|
388
522
|
res.status(404).type('text/plain').send('not found');
|
|
389
523
|
return;
|
|
390
524
|
}
|
|
525
|
+
// A large file is streamed from git to the socket and never held here;
|
|
526
|
+
// see rawCacheHeaders for what the two paths share. What the streamed
|
|
527
|
+
// path gives up is the sniff: without the bytes in hand it cannot tell
|
|
528
|
+
// text from binary, so anything that is not an image by extension is
|
|
529
|
+
// served as a download. Small files, which are the ones a browser can
|
|
530
|
+
// usefully show inline, keep the buffered path below.
|
|
531
|
+
if (info.size > STREAM_ABOVE) {
|
|
532
|
+
const ext = (filePath.split('.').pop() ?? '').toLowerCase();
|
|
533
|
+
if (rawCacheHeaders(req, res, ref, info.sha, (0, perms_1.repoIsPrivate)(loaded.repo.dir)))
|
|
534
|
+
return;
|
|
535
|
+
res.setHeader('Content-Security-Policy', 'sandbox');
|
|
536
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
537
|
+
res.setHeader('Content-Length', String(info.size));
|
|
538
|
+
res.type(exports.IMAGE_TYPES[ext] ?? 'application/octet-stream');
|
|
539
|
+
await loaded.repo.streamBlob(ref, filePath, res);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
391
542
|
const buf = await loaded.repo.catBlob(ref, filePath);
|
|
392
543
|
// A pointer blob redirects to the stored object; the filename gives the
|
|
393
544
|
// browser something better to save than a 64-character object id.
|
|
@@ -417,18 +568,8 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
417
568
|
// on purpose: a redirect to a presigned URL expires and must not be
|
|
418
569
|
// cached. A private repository's bytes were served to one reader and
|
|
419
570
|
// must never come out of a shared cache for another.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
res.setHeader('Cache-Control', `${cacheScope}, max-age=31536000, immutable`);
|
|
423
|
-
}
|
|
424
|
-
else {
|
|
425
|
-
res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
|
|
426
|
-
res.setHeader('ETag', `"${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}"`);
|
|
427
|
-
if (req.fresh) {
|
|
428
|
-
res.status(304).end();
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
571
|
+
if (rawCacheHeaders(req, res, ref, info.sha, (0, perms_1.repoIsPrivate)(loaded.repo.dir)))
|
|
572
|
+
return;
|
|
432
573
|
const ext = (filePath.split('.').pop() ?? '').toLowerCase();
|
|
433
574
|
// Repository content must never be able to inject HTML into this
|
|
434
575
|
// origin: non-image types are served as text/plain in a sandbox.
|
|
@@ -477,11 +618,8 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
477
618
|
}
|
|
478
619
|
else {
|
|
479
620
|
res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
|
|
480
|
-
|
|
481
|
-
if (req.fresh) {
|
|
482
|
-
res.status(304).end();
|
|
621
|
+
if (notModified(req, res, `"${sha}"`))
|
|
483
622
|
return;
|
|
484
|
-
}
|
|
485
623
|
}
|
|
486
624
|
// The slot is held until the stream ends, which is what the gate is for: an
|
|
487
625
|
// archive holds a subprocess and a socket for as long as the client cares
|
|
@@ -609,18 +747,18 @@ function registerBrowse(app, root, gates, lfs = null) {
|
|
|
609
747
|
// not 301: a permanent redirect would be cached hard, and removing
|
|
610
748
|
// sites.host from config.json must take effect on the next request.
|
|
611
749
|
app.get('/:collection/:repo/site/*', (req, res) => {
|
|
612
|
-
const origin = (0, site_1.
|
|
750
|
+
const origin = (0, site_1.siteRedirectUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
|
|
613
751
|
if (origin) {
|
|
614
752
|
const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
|
|
615
753
|
res.redirect(302, `${origin}/${(0, web_1.wildcard)(req)}${query}`);
|
|
616
754
|
return;
|
|
617
755
|
}
|
|
618
|
-
(0, site_1.serveSite)(root, req.params.collection, req.params.repo, req, res, 'sandbox');
|
|
756
|
+
(0, site_1.serveSite)(root, req.params.collection, req.params.repo, req, res, 'sandbox', (0, session_1.getViewer)(req, root)?.auth ?? null);
|
|
619
757
|
});
|
|
620
758
|
// The missing-slash redirect lands on the site origin in one hop rather than
|
|
621
759
|
// two when there is one.
|
|
622
760
|
app.get('/:collection/:repo/site', (req, res) => {
|
|
623
|
-
const origin = (0, site_1.
|
|
761
|
+
const origin = (0, site_1.siteRedirectUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
|
|
624
762
|
if (origin) {
|
|
625
763
|
res.redirect(302, `${origin}/`);
|
|
626
764
|
return;
|
package/dist/build-info.json
CHANGED
package/dist/ci/api.js
CHANGED
|
@@ -41,6 +41,8 @@ const express_1 = __importDefault(require("express"));
|
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const scan_1 = require("../scan");
|
|
44
|
+
const sitesettings_1 = require("../sitesettings");
|
|
45
|
+
const sitepublish_1 = require("../sitepublish");
|
|
44
46
|
const site_1 = require("../site");
|
|
45
47
|
const perms_1 = require("../perms");
|
|
46
48
|
const vault_1 = require("../vault");
|
|
@@ -171,6 +173,11 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
171
173
|
allow: r.allow,
|
|
172
174
|
createdBy: r.createdBy,
|
|
173
175
|
createdAt: r.createdAt,
|
|
176
|
+
// What was set and what is in force, the two differing for a runner
|
|
177
|
+
// left on the default: a caller wanting to know how long a job may run
|
|
178
|
+
// there should not have to know what the default is.
|
|
179
|
+
jobTimeoutMinutes: r.jobTimeoutMinutes ?? null,
|
|
180
|
+
jobTimeout: (0, runners_1.runnerJobTimeout)(r),
|
|
174
181
|
// Registration says what a runner may do; these two say whether it is
|
|
175
182
|
// there at all and what it is doing, which is what a caller looking at
|
|
176
183
|
// a run that has not started actually wants to know.
|
|
@@ -192,6 +199,13 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
192
199
|
* far as this can go: where it points is the administrator's business, and
|
|
193
200
|
* they are already trusted with a runner that executes repository code.
|
|
194
201
|
*/
|
|
202
|
+
// A wake address is somewhere the vault will send requests to, on a timer
|
|
203
|
+
// and on demand, and report back whether it answered. Every user owns a
|
|
204
|
+
// collection and so may register a runner for it, which would have made
|
|
205
|
+
// this a way for any user to have the vault probe an address of their
|
|
206
|
+
// choosing, an internal one included. So the address alone takes a site
|
|
207
|
+
// admin, as it does in the web interface; the runner itself does not.
|
|
208
|
+
const WAKE_ADMIN_ONLY = 'only a site admin may give a runner a wake address or send its wake request: the vault sends requests to whatever the address names';
|
|
195
209
|
function wakeFrom(body) {
|
|
196
210
|
const url = body.wakeUrl;
|
|
197
211
|
const secret = body.wakeSecret;
|
|
@@ -262,13 +276,30 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
262
276
|
apiError(res, 400, wake.error);
|
|
263
277
|
return;
|
|
264
278
|
}
|
|
279
|
+
if (wake.wake && !(0, perms_1.isSiteAdmin)(auth)) {
|
|
280
|
+
apiError(res, 403, WAKE_ADMIN_ONLY);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const timeout = jobTimeoutFrom(body);
|
|
284
|
+
if ('error' in timeout) {
|
|
285
|
+
apiError(res, 400, timeout.error);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
265
288
|
const { token, runner } = (0, runners_1.registerRunner)(root, name, {
|
|
266
289
|
labels: labels.length ? labels : ['ubuntu-latest'],
|
|
267
290
|
allow,
|
|
268
291
|
createdBy: auth.username,
|
|
292
|
+
jobTimeoutMinutes: timeout.minutes,
|
|
269
293
|
wake: wake.wake,
|
|
270
294
|
});
|
|
271
|
-
res.json({
|
|
295
|
+
res.json({
|
|
296
|
+
name,
|
|
297
|
+
token,
|
|
298
|
+
labels: runner.labels,
|
|
299
|
+
allow: runner.allow,
|
|
300
|
+
jobTimeout: (0, runners_1.runnerJobTimeout)(runner),
|
|
301
|
+
wakeUrl: runner.wakeUrl ?? null,
|
|
302
|
+
});
|
|
272
303
|
});
|
|
273
304
|
/**
|
|
274
305
|
* Issue a new token for a runner, invalidating the one it had.
|
|
@@ -300,6 +331,73 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
300
331
|
}
|
|
301
332
|
res.json({ name, token: issued.token, labels: issued.runner.labels, allow: issued.runner.allow });
|
|
302
333
|
});
|
|
334
|
+
/**
|
|
335
|
+
* The job timeout in a request body, or a message saying what is wrong.
|
|
336
|
+
*
|
|
337
|
+
* Absent and null both read as the default, which is what registration
|
|
338
|
+
* wants; the PATCH route below tells the two apart itself, because there an
|
|
339
|
+
* absent field is a caller who asked for nothing. A value that is not a
|
|
340
|
+
* usable number of minutes is refused rather than rounded: an operator who
|
|
341
|
+
* typed 0 meant something, and it was not "no limit".
|
|
342
|
+
*/
|
|
343
|
+
function jobTimeoutFrom(body) {
|
|
344
|
+
const v = body.jobTimeoutMinutes;
|
|
345
|
+
if (v === undefined)
|
|
346
|
+
return { minutes: null };
|
|
347
|
+
if (v === null)
|
|
348
|
+
return { minutes: null };
|
|
349
|
+
if (typeof v !== 'number' || !(0, runners_1.isUsableJobTimeout)(v)) {
|
|
350
|
+
return {
|
|
351
|
+
error: `"jobTimeoutMinutes" must be a whole number of minutes from 1 to ${runners_1.MAX_JOB_TIMEOUT_MINUTES}, or null for the default of ${runners_1.DEFAULT_JOB_TIMEOUT_MINUTES}`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { minutes: v };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The longest a job may run on this runner. A ceiling rather than a default:
|
|
358
|
+
* a job's own `timeout-minutes` applies when it asks for less, and is cut
|
|
359
|
+
* down to this when it asks for more or asks for nothing.
|
|
360
|
+
*
|
|
361
|
+
* A change applies to the next job the runner takes, not to one already
|
|
362
|
+
* running: the runner enforces the timeout it was handed with the job, and
|
|
363
|
+
* reaching into a job in flight to shorten it would be a cancellation
|
|
364
|
+
* wearing a setting's clothes.
|
|
365
|
+
*/
|
|
366
|
+
app.patch('/api/runners/:name', json, (req, res) => {
|
|
367
|
+
const auth = requireAdmin(req, res);
|
|
368
|
+
if (!auth)
|
|
369
|
+
return;
|
|
370
|
+
const name = req.params.name;
|
|
371
|
+
const existing = (0, runners_1.loadRunners)(root).runners[name];
|
|
372
|
+
if (!existing) {
|
|
373
|
+
apiError(res, 404, `no runner named ${name}`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, existing.allow)) {
|
|
377
|
+
apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const body = (req.body ?? {});
|
|
381
|
+
if (body.jobTimeoutMinutes === undefined) {
|
|
382
|
+
apiError(res, 400, 'nothing to change; provide "jobTimeoutMinutes"');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const timeout = jobTimeoutFrom(body);
|
|
386
|
+
if ('error' in timeout) {
|
|
387
|
+
apiError(res, 400, timeout.error);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const runner = (0, runners_1.setRunnerJobTimeout)(root, name, timeout.minutes);
|
|
391
|
+
if (!runner) {
|
|
392
|
+
apiError(res, 404, `no runner named ${name}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
res.json({
|
|
396
|
+
name,
|
|
397
|
+
jobTimeoutMinutes: runner.jobTimeoutMinutes ?? null,
|
|
398
|
+
jobTimeout: (0, runners_1.runnerJobTimeout)(runner),
|
|
399
|
+
});
|
|
400
|
+
});
|
|
303
401
|
/**
|
|
304
402
|
* Point a runner's wake address somewhere, or clear it with an empty body.
|
|
305
403
|
*
|
|
@@ -327,6 +425,10 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
327
425
|
apiError(res, 400, wake.error);
|
|
328
426
|
return;
|
|
329
427
|
}
|
|
428
|
+
if (wake.wake && !(0, perms_1.isSiteAdmin)(auth)) {
|
|
429
|
+
apiError(res, 403, WAKE_ADMIN_ONLY);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
330
432
|
const runner = (0, runners_1.setRunnerWake)(root, name, wake.wake);
|
|
331
433
|
res.json({ name, wakeUrl: runner?.wakeUrl ?? null });
|
|
332
434
|
});
|
|
@@ -348,8 +450,8 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
348
450
|
apiError(res, 404, `no runner named ${name}`);
|
|
349
451
|
return;
|
|
350
452
|
}
|
|
351
|
-
if (!(0, perms_1.
|
|
352
|
-
apiError(res, 403,
|
|
453
|
+
if (!(0, perms_1.isSiteAdmin)(auth)) {
|
|
454
|
+
apiError(res, 403, WAKE_ADMIN_ONLY);
|
|
353
455
|
return;
|
|
354
456
|
}
|
|
355
457
|
const wake = (0, wake_1.wakeOf)(existing);
|
|
@@ -412,7 +514,10 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
412
514
|
gone.abort();
|
|
413
515
|
}
|
|
414
516
|
});
|
|
415
|
-
const spec = await engine.waitForJob(auth.name, labels, auth.runner.allow, (0, web_1.baseUrlOf)(req),
|
|
517
|
+
const spec = await engine.waitForJob(auth.name, labels, auth.runner.allow, (0, web_1.baseUrlOf)(req),
|
|
518
|
+
// The registry decides how long a job may run on this machine, so the
|
|
519
|
+
// limit is read here rather than trusted from the runner's own request.
|
|
520
|
+
(0, runners_1.runnerJobTimeout)(auth.runner), ACQUIRE_TIMEOUT_MS, gone.signal);
|
|
416
521
|
if (!spec) {
|
|
417
522
|
if (!closed)
|
|
418
523
|
res.status(204).end();
|
|
@@ -684,6 +789,9 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
684
789
|
});
|
|
685
790
|
// ---- artifacts ----
|
|
686
791
|
//
|
|
792
|
+
/** How many distinct artifacts one run may hold; see the upload route. */
|
|
793
|
+
const MAX_ARTIFACTS_PER_RUN = 100;
|
|
794
|
+
//
|
|
687
795
|
// A job uploads under a name and a later job in the same run downloads by
|
|
688
796
|
// that name. Authorization is the job's lease, so an artifact can only be
|
|
689
797
|
// written by a job that is actually running, and only into its own run.
|
|
@@ -713,6 +821,18 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
713
821
|
}
|
|
714
822
|
const limit = engine.artifactLimitBytes();
|
|
715
823
|
fs.mkdirSync(dir, { recursive: true });
|
|
824
|
+
// The per-upload limit reads as a limit on the run, and was not one: a
|
|
825
|
+
// job that uploaded under a new name each time could fill the disk a
|
|
826
|
+
// capped artifact at a time. Replacing an artifact is always allowed;
|
|
827
|
+
// adding one is, up to a count no honest workflow approaches.
|
|
828
|
+
if (!fs.existsSync(file)) {
|
|
829
|
+
const present = fs.readdirSync(dir).filter((n) => !/\.tmp-\d+$/.test(n)).length;
|
|
830
|
+
if (present >= MAX_ARTIFACTS_PER_RUN) {
|
|
831
|
+
req.resume();
|
|
832
|
+
apiError(res, 413, `this run already holds ${present} artifacts, the most a run may hold`);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
716
836
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
717
837
|
const out = fs.createWriteStream(tmp);
|
|
718
838
|
let written = 0;
|
|
@@ -800,6 +920,20 @@ function registerCiApi(app, root, engine, authLimiter) {
|
|
|
800
920
|
apiError(res, 409, 'the lease on this job is no longer valid');
|
|
801
921
|
return;
|
|
802
922
|
}
|
|
923
|
+
// The repository's own settings gate the write, not the runner's standing:
|
|
924
|
+
// a site that is not enabled is not deployed to, and one published by
|
|
925
|
+
// copying files is not deployed to either, so a workflow cannot overwrite
|
|
926
|
+
// what somebody maintains by hand. The refusal names the setting to change.
|
|
927
|
+
const repo = (0, scan_1.findRepo)(root, a.collection, a.repo);
|
|
928
|
+
const settings = repo ? (0, sitesettings_1.siteSettings)(repo.dir) : null;
|
|
929
|
+
if (!settings || !settings.enabled) {
|
|
930
|
+
apiError(res, 403, `the site for ${a.collection}/${a.repo} is not enabled; a repository admin can enable it in the repository's settings`);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (settings.source !== 'actions') {
|
|
934
|
+
apiError(res, 403, `the site for ${a.collection}/${a.repo} is published by ${(0, sitepublish_1.describeSiteSource)(settings.source)}, not by workflow deploys; set its site source to workflow deploys first`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
803
937
|
const body = (req.body ?? {});
|
|
804
938
|
const name = typeof body.artifact === 'string' ? body.artifact : 'github-pages';
|
|
805
939
|
if (!(0, artifacts_1.isValidArtifactName)(name)) {
|