@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/runner-cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runnerListCommand = void 0;
|
|
4
4
|
exports.runnerAddCmd = runnerAddCmd;
|
|
5
|
+
exports.runnerEditCmd = runnerEditCmd;
|
|
5
6
|
exports.runnerWakeCmd = runnerWakeCmd;
|
|
6
7
|
exports.runnerRemoveCmd = runnerRemoveCmd;
|
|
7
8
|
exports.runnerRunCmd = runnerRunCmd;
|
|
@@ -31,6 +32,23 @@ function parseDuration(text) {
|
|
|
31
32
|
const unit = (m[2] ?? 's').toLowerCase();
|
|
32
33
|
return unit === 'h' ? n * 3600 : unit === 'm' ? n * 60 : n;
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* "20", "45m", "2h" -> minutes, for the job timeout.
|
|
37
|
+
*
|
|
38
|
+
* Separate from parseDuration above, and in a different unit, because a bare
|
|
39
|
+
* number means different things in the two places: seconds is the natural
|
|
40
|
+
* reading of --idle and the wrong reading of a job timeout, which an operator
|
|
41
|
+
* states in minutes.
|
|
42
|
+
*/
|
|
43
|
+
function parseTimeoutMinutes(text) {
|
|
44
|
+
const m = /^(\d+)\s*(m|h)?$/i.exec(text.trim());
|
|
45
|
+
if (!m) {
|
|
46
|
+
console.error(`--job-timeout takes minutes, or a duration like 45m or 2h, got: ${text}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const n = parseInt(m[1], 10);
|
|
50
|
+
return (m[2] ?? 'm').toLowerCase() === 'h' ? n * 60 : n;
|
|
51
|
+
}
|
|
34
52
|
function parseArgs(args, usage) {
|
|
35
53
|
const out = {
|
|
36
54
|
name: null,
|
|
@@ -42,6 +60,9 @@ function parseArgs(args, usage) {
|
|
|
42
60
|
images: {},
|
|
43
61
|
workDir: null,
|
|
44
62
|
network: null,
|
|
63
|
+
jobMemory: null,
|
|
64
|
+
jobCpus: null,
|
|
65
|
+
jobPids: null,
|
|
45
66
|
cacheDir: null,
|
|
46
67
|
actionsUrl: null,
|
|
47
68
|
actionCache: true,
|
|
@@ -51,6 +72,7 @@ function parseArgs(args, usage) {
|
|
|
51
72
|
wakeSecret: null,
|
|
52
73
|
wakeUrl: null,
|
|
53
74
|
clear: false,
|
|
75
|
+
jobTimeout: null,
|
|
54
76
|
engine: null,
|
|
55
77
|
};
|
|
56
78
|
const list = (v) => v.split(/[\s,]+/).filter((s) => s.length > 0);
|
|
@@ -78,6 +100,27 @@ function parseArgs(args, usage) {
|
|
|
78
100
|
out.actionCache = false;
|
|
79
101
|
else if (a === '--network')
|
|
80
102
|
out.network = args[++i];
|
|
103
|
+
else if (a === '--job-memory' || a === '--job-cpus') {
|
|
104
|
+
// Passed through to the engine as written, which is the authority on
|
|
105
|
+
// what "2g" or "1.5" means; only an empty value is refused here.
|
|
106
|
+
const v = (args[++i] ?? '').trim();
|
|
107
|
+
if (!/^[0-9][0-9.]*[bkmgBKMG]?$/.test(v)) {
|
|
108
|
+
console.error(`${a} takes a number, as docker run does (${a === '--job-memory' ? '2g, 512m' : '2, 0.5'}), got: ${v}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
if (a === '--job-memory')
|
|
112
|
+
out.jobMemory = v;
|
|
113
|
+
else
|
|
114
|
+
out.jobCpus = v;
|
|
115
|
+
}
|
|
116
|
+
else if (a === '--job-pids') {
|
|
117
|
+
const n = parseInt(args[++i] ?? '', 10);
|
|
118
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
119
|
+
console.error('--job-pids takes a number of processes, or 0 for no limit');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
out.jobPids = n;
|
|
123
|
+
}
|
|
81
124
|
else if (a === '--idle')
|
|
82
125
|
out.idleSeconds = parseDuration(args[++i] ?? '');
|
|
83
126
|
else if (a === '--wake-port') {
|
|
@@ -94,6 +137,13 @@ function parseArgs(args, usage) {
|
|
|
94
137
|
out.wakeUrl = args[++i];
|
|
95
138
|
else if (a === '--clear')
|
|
96
139
|
out.clear = true;
|
|
140
|
+
else if (a === '--job-timeout') {
|
|
141
|
+
// Empty (or the word 'default') puts the runner back on the vault's
|
|
142
|
+
// default, which is a real answer and not a missing one; anything else
|
|
143
|
+
// is minutes, or a duration for an operator who thinks in hours.
|
|
144
|
+
const raw = (args[++i] ?? '').trim();
|
|
145
|
+
out.jobTimeout = raw === '' || raw === 'default' ? 'default' : parseTimeoutMinutes(raw);
|
|
146
|
+
}
|
|
97
147
|
else if (a === '--save')
|
|
98
148
|
out.save = true;
|
|
99
149
|
else if (a === '--engine') {
|
|
@@ -150,11 +200,16 @@ async function runnerAddCmd(args, usage) {
|
|
|
150
200
|
name: a.name,
|
|
151
201
|
labels,
|
|
152
202
|
allow: a.allow,
|
|
203
|
+
...(typeof a.jobTimeout === 'number' ? { jobTimeoutMinutes: a.jobTimeout } : {}),
|
|
153
204
|
...(wake ? { wakeUrl: wake.url, wakeSecret: wake.secret } : {}),
|
|
154
205
|
});
|
|
155
206
|
console.log(`Registered runner ${data.name}`);
|
|
156
207
|
console.log(` labels: ${data.labels.join(', ')}`);
|
|
157
208
|
console.log(` serving: ${data.allow.join(', ')}`);
|
|
209
|
+
// A vault older than the setting reports no timeout, and saying nothing is
|
|
210
|
+
// better than reporting a default this CLI only assumes it has.
|
|
211
|
+
if (data.jobTimeout !== undefined)
|
|
212
|
+
console.log(` timeout: ${String(data.jobTimeout)} minutes per job`);
|
|
158
213
|
if (wake)
|
|
159
214
|
console.log(` wake: ${wake.url}`);
|
|
160
215
|
console.log('');
|
|
@@ -191,6 +246,34 @@ async function runnerAddCmd(args, usage) {
|
|
|
191
246
|
}
|
|
192
247
|
console.log('');
|
|
193
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* `mochi runner edit <name> --job-timeout <d>`: change what a registered runner
|
|
251
|
+
* is allowed to do, which for now is how long a job may run on it.
|
|
252
|
+
*
|
|
253
|
+
* Its labels and allow list are deliberately not here: both decide which jobs
|
|
254
|
+
* a machine may take, and changing them under a runner that is already trusted
|
|
255
|
+
* with a set of repositories is a re-registration rather than an edit.
|
|
256
|
+
*/
|
|
257
|
+
async function runnerEditCmd(args, usage) {
|
|
258
|
+
const a = parseArgs(args, usage);
|
|
259
|
+
if (!a.name) {
|
|
260
|
+
console.error('Which runner? Usage: mochi runner edit <name> --job-timeout <minutes>');
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
if (a.jobTimeout === null) {
|
|
264
|
+
console.error("Nothing to change. Pass --job-timeout <minutes>, or --job-timeout '' for the default.");
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
const target = await (0, cli_api_1.remoteTarget)(a);
|
|
268
|
+
const data = await (0, cli_api_1.api)(target, 'PATCH', `/api/runners/${encodeURIComponent(a.name)}`, {
|
|
269
|
+
jobTimeoutMinutes: a.jobTimeout === 'default' ? null : a.jobTimeout,
|
|
270
|
+
});
|
|
271
|
+
const minutes = Number(data.jobTimeout);
|
|
272
|
+
console.log(data.jobTimeoutMinutes === null
|
|
273
|
+
? `Runner ${a.name} is back on the vault's default job timeout of ${minutes} minutes.`
|
|
274
|
+
: `Jobs on ${a.name} now stop after ${minutes} minute${minutes === 1 ? '' : 's'}.`);
|
|
275
|
+
console.log('Applies to the next job it takes; one already running keeps the timeout it started with.');
|
|
276
|
+
}
|
|
194
277
|
/**
|
|
195
278
|
* `mochi runner wake <name>`: send the wake request, or change where it goes.
|
|
196
279
|
*
|
|
@@ -292,6 +375,7 @@ is the usual reason a run sits at queued forever.`,
|
|
|
292
375
|
r.name,
|
|
293
376
|
`labels: ${r.labels.join(', ') || '(none)'}`,
|
|
294
377
|
`serving: ${r.allow.join(', ') || '(none)'}`,
|
|
378
|
+
r.jobTimeout === undefined ? '' : `timeout: ${r.jobTimeout}m`,
|
|
295
379
|
// A vault older than liveness reporting leaves both out, and saying
|
|
296
380
|
// "seen never" of a runner that may be perfectly healthy would be
|
|
297
381
|
// worse than saying nothing.
|
|
@@ -360,6 +444,11 @@ async function runnerRunCmd(args, usage) {
|
|
|
360
444
|
images: { ...client_1.DEFAULT_IMAGES, ...(saved?.images ?? {}), ...a.images },
|
|
361
445
|
workDir: a.workDir ?? saved?.workDir,
|
|
362
446
|
network: a.network ?? saved?.network,
|
|
447
|
+
limits: {
|
|
448
|
+
memory: a.jobMemory ?? saved?.limits?.memory,
|
|
449
|
+
cpus: a.jobCpus ?? saved?.limits?.cpus,
|
|
450
|
+
pids: a.jobPids ?? saved?.limits?.pids,
|
|
451
|
+
},
|
|
363
452
|
cacheDir: a.cacheDir ?? saved?.cacheDir,
|
|
364
453
|
actionsUrl: a.actionsUrl ?? saved?.actionsUrl,
|
|
365
454
|
actionCache: a.actionCache && (saved?.actionCache ?? true),
|
package/dist/scan.js
CHANGED
|
@@ -33,10 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.repoSiblingSuffixes = void 0;
|
|
36
|
+
exports.repoSiblingSuffixes = exports.MAX_NAME_LENGTH = void 0;
|
|
37
37
|
exports.isValidName = isValidName;
|
|
38
38
|
exports.isDotName = isDotName;
|
|
39
39
|
exports.isValidUserName = isValidUserName;
|
|
40
|
+
exports.collectionCaseClash = collectionCaseClash;
|
|
41
|
+
exports.repoCaseClash = repoCaseClash;
|
|
40
42
|
exports.reservedRepoSuffix = reservedRepoSuffix;
|
|
41
43
|
exports.isBareRepo = isBareRepo;
|
|
42
44
|
exports.displayName = displayName;
|
|
@@ -45,11 +47,13 @@ exports.listCollections = listCollections;
|
|
|
45
47
|
exports.findRepo = findRepo;
|
|
46
48
|
exports.siteDir = siteDir;
|
|
47
49
|
exports.forkParent = forkParent;
|
|
50
|
+
exports.upstreamOf = upstreamOf;
|
|
48
51
|
exports.repoDescription = repoDescription;
|
|
49
52
|
const fs = __importStar(require("fs"));
|
|
50
53
|
const path = __importStar(require("path"));
|
|
51
54
|
const git_1 = require("./git");
|
|
52
55
|
const layout_1 = require("./layout");
|
|
56
|
+
const source_1 = require("./source");
|
|
53
57
|
// Names the UI owns as top-level path segments. None of these may ever be a
|
|
54
58
|
// collection or repo name, since a collection is reached at /<collection> and
|
|
55
59
|
// a repository at /<collection>/<repo>. The vault's own files are not among
|
|
@@ -67,6 +71,7 @@ const RESERVED_NAMES = new Set([
|
|
|
67
71
|
'import',
|
|
68
72
|
'admin',
|
|
69
73
|
'settings',
|
|
74
|
+
'topics',
|
|
70
75
|
]);
|
|
71
76
|
function isValidName(name) {
|
|
72
77
|
if (RESERVED_NAMES.has(name))
|
|
@@ -96,7 +101,41 @@ function isDotName(name) {
|
|
|
96
101
|
* created, since only a repository may carry one.
|
|
97
102
|
*/
|
|
98
103
|
function isValidUserName(name) {
|
|
99
|
-
return isValidName(name) && !isDotName(name);
|
|
104
|
+
return isValidName(name) && !isDotName(name) && name.length <= exports.MAX_NAME_LENGTH;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The longest name a collection, repository, or user may be created under.
|
|
108
|
+
* Far below any filesystem limit on purpose: the interface has to render
|
|
109
|
+
* these, and the on-disk form carries suffixes (`<name>.git`, `<name>.issues`)
|
|
110
|
+
* that a name near the 255-byte filename limit would push over it. Enforced
|
|
111
|
+
* only where a name comes into being; reading stays permissive, so a vault
|
|
112
|
+
* that somehow holds a longer one still serves it.
|
|
113
|
+
*/
|
|
114
|
+
exports.MAX_NAME_LENGTH = 100;
|
|
115
|
+
/**
|
|
116
|
+
* The existing collection whose name matches `name` apart from letter case, or
|
|
117
|
+
* null when none does (an exact match does not count). Creation refuses such a
|
|
118
|
+
* near-duplicate: two collections telling apart only by case are confusing in
|
|
119
|
+
* every listing and cannot coexist on a case-insensitive filesystem, which is
|
|
120
|
+
* where a vault copied to macOS or Windows would find itself.
|
|
121
|
+
*/
|
|
122
|
+
function collectionCaseClash(root, name) {
|
|
123
|
+
const lower = name.toLowerCase();
|
|
124
|
+
for (const c of listCollections(root)) {
|
|
125
|
+
if (c.name !== name && c.name.toLowerCase() === lower)
|
|
126
|
+
return c.name;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** As collectionCaseClash, for a repository name within one collection. */
|
|
131
|
+
function repoCaseClash(root, collection, name) {
|
|
132
|
+
const lower = name.toLowerCase();
|
|
133
|
+
for (const d of listRepoDirs(root, collection)) {
|
|
134
|
+
const existing = displayName(d);
|
|
135
|
+
if (existing !== name && existing.toLowerCase() === lower)
|
|
136
|
+
return existing;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
100
139
|
}
|
|
101
140
|
// The directories a repository accumulates beside its bare repository, by the
|
|
102
141
|
// suffix each one carries. Every feature has its own helper for the directory
|
|
@@ -218,6 +257,23 @@ function forkParent(dir) {
|
|
|
218
257
|
return null;
|
|
219
258
|
return { collection, repo };
|
|
220
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The URL outside this vault the repository was forked from, or null. Written
|
|
262
|
+
* by `mochi fork` as `mochi.upstream` and read the same way forkParent is:
|
|
263
|
+
* out of the config file directly, since this too is asked on page renders.
|
|
264
|
+
* A stored value that parseUpstream rejects reads as no upstream.
|
|
265
|
+
*/
|
|
266
|
+
function upstreamOf(dir) {
|
|
267
|
+
let text;
|
|
268
|
+
try {
|
|
269
|
+
text = fs.readFileSync(path.join(dir, 'config'), 'utf8');
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const m = text.match(/^\s*upstream\s*=\s*(\S+)\s*$/m);
|
|
275
|
+
return m ? (0, source_1.parseUpstream)(m[1]) : null;
|
|
276
|
+
}
|
|
221
277
|
function repoDescription(dir) {
|
|
222
278
|
try {
|
|
223
279
|
const t = fs.readFileSync(path.join(dir, 'description'), 'utf8').trim();
|
package/dist/server.js
CHANGED
|
@@ -60,15 +60,18 @@ const releases_1 = require("./releases");
|
|
|
60
60
|
const lfsstore_1 = require("./lfsstore");
|
|
61
61
|
const layout_1 = require("./layout");
|
|
62
62
|
const logo_1 = require("./logo");
|
|
63
|
+
const maintenance_1 = require("./maintenance");
|
|
63
64
|
const migrate_1 = require("./migrate");
|
|
64
65
|
const perms_1 = require("./perms");
|
|
65
66
|
const redirects_1 = require("./redirects");
|
|
66
67
|
const scan_1 = require("./scan");
|
|
68
|
+
const topics_1 = require("./topics");
|
|
67
69
|
const session_1 = require("./session");
|
|
68
70
|
const site_1 = require("./site");
|
|
69
|
-
const
|
|
71
|
+
const domains_1 = require("./domains");
|
|
70
72
|
const assets_1 = require("./assets");
|
|
71
73
|
const pagescript_1 = require("./pagescript");
|
|
74
|
+
const agescript_1 = require("./agescript");
|
|
72
75
|
const themes_1 = require("./themes");
|
|
73
76
|
const views = __importStar(require("./views"));
|
|
74
77
|
const webops_1 = require("./webops");
|
|
@@ -130,6 +133,14 @@ function isCompressible(req, res) {
|
|
|
130
133
|
* obtained from a signed-in reader who thought they were clicking something of
|
|
131
134
|
* the framing page's. base-uri and form-action close the two ways injected
|
|
132
135
|
* markup could redirect a relative URL or a form post without running script.
|
|
136
|
+
*
|
|
137
|
+
* form-action names github.com beside 'self' because browsers apply the
|
|
138
|
+
* directive to a redirect that follows a form submission, not only to the
|
|
139
|
+
* form's own target: the account page's "Link a GitHub account" form posts to
|
|
140
|
+
* this vault, whose answer is a redirect to GitHub's authorize page (see
|
|
141
|
+
* src/webops.ts), and without the entry the browser blocks that redirect. An
|
|
142
|
+
* injected form is not helped by it: posting to github.com carries nothing of
|
|
143
|
+
* the vault's, and the sign-in cookie in particular never leaves 'self'.
|
|
133
144
|
*/
|
|
134
145
|
const FORGE_CSP = [
|
|
135
146
|
"default-src 'self'",
|
|
@@ -140,13 +151,13 @@ const FORGE_CSP = [
|
|
|
140
151
|
"connect-src 'self'",
|
|
141
152
|
"object-src 'none'",
|
|
142
153
|
"base-uri 'none'",
|
|
143
|
-
"form-action 'self'",
|
|
154
|
+
"form-action 'self' https://github.com",
|
|
144
155
|
"frame-ancestors 'self'",
|
|
145
156
|
].join('; ');
|
|
146
157
|
function isRateExempt(root, req) {
|
|
147
158
|
if (req.path.startsWith('/api/runner/'))
|
|
148
159
|
return true;
|
|
149
|
-
if ((0,
|
|
160
|
+
if ((0, domains_1.isSiteRequest)(root, req.hostname))
|
|
150
161
|
return false;
|
|
151
162
|
return req.path.startsWith('/assets/') || req.path === '/favicon.svg' || req.path === '/favicon.ico';
|
|
152
163
|
}
|
|
@@ -167,8 +178,19 @@ function createApp(root) {
|
|
|
167
178
|
for (const note of permNotes)
|
|
168
179
|
console.log(`permissions: ${note}`);
|
|
169
180
|
}
|
|
181
|
+
// And a vault from before force pushes were allowed, whose repositories
|
|
182
|
+
// would otherwise go on refusing them while every new one accepted them.
|
|
183
|
+
const unfrozen = (0, migrate_1.migratePushPolicy)(root);
|
|
184
|
+
if (unfrozen.length > 0) {
|
|
185
|
+
console.log(`push policy: force pushes now allowed on ${unfrozen.join(', ')}`);
|
|
186
|
+
}
|
|
170
187
|
const app = (0, express_1.default)();
|
|
171
188
|
app.disable('x-powered-by');
|
|
189
|
+
// Node's own querystring rather than the qs package express 4 defaults to.
|
|
190
|
+
// Nothing here reads a nested query (?a[b]=c), and qs is where express 4's
|
|
191
|
+
// open advisories live: the parser runs on every anonymous request, so the
|
|
192
|
+
// package is better not run at all than trusted to be patched in time.
|
|
193
|
+
app.set('query parser', 'simple');
|
|
172
194
|
const config = (0, config_1.loadConfig)(root);
|
|
173
195
|
// Behind a TLS proxy (Caddy, Fly, etc.) X-Forwarded-Proto/Host must win, or
|
|
174
196
|
// clone URLs and Secure cookies would use the internal address. On a vault
|
|
@@ -182,7 +204,14 @@ function createApp(root) {
|
|
|
182
204
|
// internet is trusted is a restart-worthy event in a way that changing a
|
|
183
205
|
// colour scheme is not. The same goes for the limits below, which hold live
|
|
184
206
|
// counts that cannot be rebuilt per request without discarding them.
|
|
185
|
-
|
|
207
|
+
//
|
|
208
|
+
// One hop, not `true`: with `true`, req.ip is the leftmost address in
|
|
209
|
+
// X-Forwarded-For, which is whatever the client wrote there when the proxy
|
|
210
|
+
// appends to the header rather than replacing it (Fly's and nginx's do),
|
|
211
|
+
// and every per-address limit would be the client's to choose again. One
|
|
212
|
+
// hop is the address the proxy itself saw, the last in the list, which is
|
|
213
|
+
// right both for a proxy that appends and for one that replaces (Caddy's).
|
|
214
|
+
app.set('trust proxy', config.network.trustProxy ? 1 : false);
|
|
186
215
|
// Outgoing bytes, counted per repository and capped per day. The cap is read
|
|
187
216
|
// through loadConfig on every call rather than captured here, unlike the rest
|
|
188
217
|
// of the limits block: it is the one limit whose value an operator needs to
|
|
@@ -252,7 +281,8 @@ function createApp(root) {
|
|
|
252
281
|
// Sites served from their own hostname are answered before every other route,
|
|
253
282
|
// the asset routes included: otherwise /assets/style.css would give a site the
|
|
254
283
|
// forge's stylesheet instead of its own, and /favicon.svg the forge's icon.
|
|
255
|
-
// Nothing here runs unless a sites host is configured
|
|
284
|
+
// Nothing here runs unless a sites host is configured or a custom domain is
|
|
285
|
+
// mapped in domains.json.
|
|
256
286
|
(0, site_1.registerSiteHost)(app, root);
|
|
257
287
|
// The forge's own pages may only be framed by the forge. A CSRF token and the
|
|
258
288
|
// Origin check in src/session.ts refuse a request forged from somewhere else,
|
|
@@ -269,6 +299,14 @@ function createApp(root) {
|
|
|
269
299
|
// ordinary for a static site and no business of the forge's.
|
|
270
300
|
app.use((_req, res, next) => {
|
|
271
301
|
res.setHeader('Content-Security-Policy', FORGE_CSP);
|
|
302
|
+
// On every response, not only the raw file routes that already carried it:
|
|
303
|
+
// nothing the forge serves relies on type sniffing, so there is no reason
|
|
304
|
+
// to leave any response open to it.
|
|
305
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
306
|
+
// The browser's own default, stated explicitly so a link out of a private
|
|
307
|
+
// vault never carries a path in its referrer whatever the browser's
|
|
308
|
+
// settings say.
|
|
309
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
272
310
|
next();
|
|
273
311
|
});
|
|
274
312
|
// Then, before any route that resolves a name: an address a rename left
|
|
@@ -303,6 +341,19 @@ function createApp(root) {
|
|
|
303
341
|
.set('X-Content-Type-Options', 'nosniff')
|
|
304
342
|
.send(script.body);
|
|
305
343
|
});
|
|
344
|
+
// The encrypted-file script, on the same terms again. It is ~300 KB of
|
|
345
|
+
// vendored cryptography plus its glue, which is why only the pages that
|
|
346
|
+
// need it link it (see PageOpts.ageScript) and why the immutable caching
|
|
347
|
+
// matters more here than anywhere.
|
|
348
|
+
app.get('/assets/age.js', (req, res) => {
|
|
349
|
+
const script = (0, agescript_1.ageScript)();
|
|
350
|
+
const fresh = String(req.query.v ?? '') === script.tag;
|
|
351
|
+
res
|
|
352
|
+
.type('text/javascript')
|
|
353
|
+
.set('Cache-Control', fresh ? 'public, max-age=31536000, immutable' : 'no-cache')
|
|
354
|
+
.set('X-Content-Type-Options', 'nosniff')
|
|
355
|
+
.send(script.body);
|
|
356
|
+
});
|
|
306
357
|
app.get('/assets/hl.css', (req, res) => {
|
|
307
358
|
// Code colours are a whole stylesheet rather than a set of tokens, so the
|
|
308
359
|
// reader's theme picks a file instead of an attribute. The name still
|
|
@@ -321,23 +372,26 @@ function createApp(root) {
|
|
|
321
372
|
}
|
|
322
373
|
res.type('text/css').set('Cache-Control', 'public, max-age=86400').send(css);
|
|
323
374
|
});
|
|
324
|
-
// Every repository the interface would show this viewer anyway, as
|
|
325
|
-
//
|
|
326
|
-
// It says no more than the front page already does to the same
|
|
327
|
-
// private repository is listed only for a viewer with a role there,
|
|
328
|
-
// is also why the answer is marked private to shared caches. It is
|
|
329
|
-
// /api/repos, which is the authenticated interface for programs and
|
|
330
|
-
// much more per repository
|
|
375
|
+
// Every repository the interface would show this viewer anyway, as names
|
|
376
|
+
// and topics, for the jump box to search without a round trip per
|
|
377
|
+
// keystroke. It says no more than the front page already does to the same
|
|
378
|
+
// eyes: a private repository is listed only for a viewer with a role there,
|
|
379
|
+
// which is also why the answer is marked private to shared caches. It is
|
|
380
|
+
// not /api/repos, which is the authenticated interface for programs and
|
|
381
|
+
// carries much more per repository. Topics ride along only where a
|
|
382
|
+
// repository has them, so the common entry stays one name.
|
|
331
383
|
app.get('/assets/repos.json', (req, res) => {
|
|
332
384
|
const viewer = (0, session_1.getViewer)(req, root);
|
|
333
385
|
const repos = [];
|
|
334
386
|
for (const c of (0, scan_1.listCollections)(root)) {
|
|
335
387
|
for (const d of (0, scan_1.listRepoDirs)(root, c.name)) {
|
|
336
388
|
const name = (0, scan_1.displayName)(d);
|
|
337
|
-
|
|
389
|
+
const dir = (0, layout_1.repoPath)(root, c.name, d);
|
|
390
|
+
if ((0, perms_1.repoRole)(root, viewer?.auth ?? null, { collection: c.name, name, dir }) === null) {
|
|
338
391
|
continue;
|
|
339
392
|
}
|
|
340
|
-
|
|
393
|
+
const topics = (0, topics_1.repoTopics)(dir);
|
|
394
|
+
repos.push(topics.length ? { name: `${c.name}/${name}`, topics } : { name: `${c.name}/${name}` });
|
|
341
395
|
}
|
|
342
396
|
}
|
|
343
397
|
res.set('Cache-Control', 'private, no-cache').json(repos);
|
|
@@ -378,11 +432,24 @@ function createApp(root) {
|
|
|
378
432
|
// The CI engine plans and schedules workflow runs; jobs execute on runners
|
|
379
433
|
// elsewhere (mochi runner run), never in this process.
|
|
380
434
|
const engine = new engine_1.CiEngine(root, () => (0, config_1.loadConfig)(root).ci);
|
|
435
|
+
// Unreachable objects, from a force push or a branch deleted or a history
|
|
436
|
+
// rewritten, are collected on a slow timer with a generous grace period. See
|
|
437
|
+
// src/maintenance.ts for why it is safe to run beside live traffic.
|
|
438
|
+
(0, maintenance_1.startMaintenance)(root);
|
|
381
439
|
// A runner that stops when it has nothing to do cannot be told that it has
|
|
382
440
|
// something to do, so the vault starts it: this watches for a queued job
|
|
383
441
|
// whose runner is not connected and pokes that runner's wake address.
|
|
384
442
|
// Runners without one are unaffected, which is all of them by default.
|
|
385
443
|
(0, wake_1.startWakeDispatcher)(root, engine);
|
|
444
|
+
// Sliding sessions: a session cookie seen in the second half of its life is
|
|
445
|
+
// re-issued with a fresh expiry, so regular use never runs into a sign-out.
|
|
446
|
+
// Registered after the asset routes above on purpose -- several of them are
|
|
447
|
+
// publicly cacheable, and a Set-Cookie must never ride on a response a
|
|
448
|
+
// shared cache may store and replay to someone else.
|
|
449
|
+
app.use((req, res, next) => {
|
|
450
|
+
(0, session_1.renewSession)(req, res, root);
|
|
451
|
+
next();
|
|
452
|
+
});
|
|
386
453
|
// Registration order matters: the API and the UI-owned top-level paths
|
|
387
454
|
// (/login, /new, /admin, ...) come before the generic /:collection and /:collection/:repo
|
|
388
455
|
// browse routes, and more-specific wildcard routes before their prefixes.
|
|
@@ -405,20 +472,34 @@ function createApp(root) {
|
|
|
405
472
|
res.status(404).type('html').send(views.errorPage(404, 'Page not found', { viewer: (0, session_1.getViewer)(req, root) }));
|
|
406
473
|
});
|
|
407
474
|
app.use((err, req, res, _next) => {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
let viewer = null;
|
|
411
|
-
try {
|
|
412
|
-
viewer = (0, session_1.getViewer)(req, root);
|
|
413
|
-
}
|
|
414
|
-
catch {
|
|
415
|
-
viewer = null;
|
|
416
|
-
}
|
|
417
|
-
res.status(500).type('html').send(views.errorPage(500, 'Internal server error', { viewer }));
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
475
|
+
if (res.headersSent) {
|
|
476
|
+
console.error(err);
|
|
420
477
|
res.end();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
let viewer = null;
|
|
481
|
+
try {
|
|
482
|
+
viewer = (0, session_1.getViewer)(req, root);
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
viewer = null;
|
|
421
486
|
}
|
|
487
|
+
// The body parsers throw typed errors carrying the status they deserve --
|
|
488
|
+
// a body over the route's limit, a malformed encoding, an aborted upload.
|
|
489
|
+
// Those are refusals of the request, not failures of the server, so they
|
|
490
|
+
// get their status and a sentence a person can act on rather than the bare
|
|
491
|
+
// 500 below.
|
|
492
|
+
const status = err.statusCode ?? err.status;
|
|
493
|
+
const type = err.type;
|
|
494
|
+
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
495
|
+
const message = type === 'entity.too.large'
|
|
496
|
+
? 'What you submitted is larger than this form accepts. Files this size can go through the Upload files form, or a git push.'
|
|
497
|
+
: 'The request could not be read; go back and try again.';
|
|
498
|
+
res.status(status).type('html').send(views.errorPage(status, message, { viewer }));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
console.error(err);
|
|
502
|
+
res.status(500).type('html').send(views.errorPage(500, 'Internal server error', { viewer }));
|
|
422
503
|
});
|
|
423
504
|
return app;
|
|
424
505
|
}
|