@magland/mochi 0.3.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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.registerApi = registerApi;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const express_1 = __importDefault(require("express"));
|
|
42
|
+
const ops = __importStar(require("./ops"));
|
|
43
|
+
const ops_1 = require("./ops");
|
|
44
|
+
const layout_1 = require("./layout");
|
|
45
|
+
const perms_1 = require("./perms");
|
|
46
|
+
const scan_1 = require("./scan");
|
|
47
|
+
const vault_1 = require("./vault");
|
|
48
|
+
const auth_1 = require("./api/auth");
|
|
49
|
+
const contents_1 = require("./api/contents");
|
|
50
|
+
const issues_1 = require("./api/issues");
|
|
51
|
+
const pulls_1 = require("./api/pulls");
|
|
52
|
+
const repos_1 = require("./api/repos");
|
|
53
|
+
const admin_1 = require("./api/admin");
|
|
54
|
+
const backup_1 = require("./api/backup");
|
|
55
|
+
const ci_1 = require("./api/ci");
|
|
56
|
+
const releases_1 = require("./api/releases");
|
|
57
|
+
const write_1 = require("./api/write");
|
|
58
|
+
// The bearer-token JSON API used by the mochi CLI. Only Bearer tokens are
|
|
59
|
+
// accepted; session cookies never authorize API calls.
|
|
60
|
+
function registerApi(app, root, authLimiter, gates, lfs = null, engine, egress) {
|
|
61
|
+
// A write route carries a file in its body, so the limit is the one the upload
|
|
62
|
+
// route already applies rather than express's 100 kB default. Anything larger
|
|
63
|
+
// belongs in a push, or in Git LFS.
|
|
64
|
+
app.use('/api', express_1.default.json({ limit: ops_1.MAX_UPLOAD_SIZE }));
|
|
65
|
+
// The routes are split by subject, mirroring the split the HTML modules
|
|
66
|
+
// already have, so that no one file grows unmanageable. Each of them calls the
|
|
67
|
+
// same domain functions the web handlers call and the same authorization
|
|
68
|
+
// helpers, so the duplication between the two transports is transport only.
|
|
69
|
+
(0, repos_1.registerRepoApi)(app, root, authLimiter);
|
|
70
|
+
(0, contents_1.registerContentsApi)(app, root, authLimiter, gates);
|
|
71
|
+
(0, issues_1.registerIssueApi)(app, root, authLimiter);
|
|
72
|
+
(0, pulls_1.registerPullApi)(app, root, authLimiter, engine);
|
|
73
|
+
(0, write_1.registerWriteApi)(app, root, authLimiter, lfs, engine);
|
|
74
|
+
// The engine is constructed in createApp and already handed to registerCiApi,
|
|
75
|
+
// so these routes take it the same way. A vault serving with no engine has no
|
|
76
|
+
// workflows to answer about.
|
|
77
|
+
if (engine)
|
|
78
|
+
(0, ci_1.registerCiRunApi)(app, root, authLimiter, engine);
|
|
79
|
+
(0, releases_1.registerReleaseApi)(app, root, authLimiter);
|
|
80
|
+
(0, admin_1.registerAdminApi)(app, root, authLimiter, lfs, engine, egress);
|
|
81
|
+
// Reading a whole vault out over HTTP, for `mochi backup`. Admin over the
|
|
82
|
+
// whole vault, and behind the same gate a file listing holds.
|
|
83
|
+
(0, backup_1.registerBackupApi)(app, root, authLimiter, gates);
|
|
84
|
+
// Both helpers live in src/api/auth.ts now that more than one file of routes
|
|
85
|
+
// uses them; this closure only saves passing root at every call site.
|
|
86
|
+
const requireApiAuth = (req, res) => (0, auth_1.requireApiAuth)(root, authLimiter, req, res);
|
|
87
|
+
function sanitizeGlobs(v) {
|
|
88
|
+
if (v === undefined || v === null)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (Array.isArray(v) && v.every((x) => typeof x === 'string' && x.length > 0 && x.length < 200)) {
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
// What a caller may reach, filtered to their eyes: a private repository the
|
|
96
|
+
// caller has no role on is left out, here and in every listing.
|
|
97
|
+
const visibleRepos = (auth, collection) => (0, scan_1.listRepoDirs)(root, collection).filter((dirName) => (0, perms_1.repoRole)(root, auth, {
|
|
98
|
+
collection,
|
|
99
|
+
name: (0, scan_1.displayName)(dirName),
|
|
100
|
+
dir: (0, layout_1.repoPath)(root, collection, dirName),
|
|
101
|
+
}) !== null);
|
|
102
|
+
app.get('/api/whoami', (req, res) => {
|
|
103
|
+
const auth = requireApiAuth(req, res);
|
|
104
|
+
if (!auth)
|
|
105
|
+
return;
|
|
106
|
+
res.json({
|
|
107
|
+
username: auth.username,
|
|
108
|
+
siteAdmin: auth.user.siteAdmin === true,
|
|
109
|
+
ownedCollections: (0, scan_1.listCollections)(root)
|
|
110
|
+
.map((c) => c.name)
|
|
111
|
+
.filter((c) => c === auth.username || (0, perms_1.collectionOwners)(root, c).includes(auth.username)),
|
|
112
|
+
tokenScope: auth.token.scope ?? null,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// Collections, for the CLI. `mochi import` asks what is already in a
|
|
116
|
+
// collection before it pushes, and `mochi collection add` makes an empty
|
|
117
|
+
// one, which is the case a push cannot cover: pushing creates the collection
|
|
118
|
+
// it lands in, so a collection with nothing in it yet has to be asked for.
|
|
119
|
+
app.get('/api/collections', (req, res) => {
|
|
120
|
+
const auth = requireApiAuth(req, res);
|
|
121
|
+
if (!auth)
|
|
122
|
+
return;
|
|
123
|
+
res.json({
|
|
124
|
+
collections: (0, scan_1.listCollections)(root).map((c) => ({ name: c.name, repoCount: visibleRepos(auth, c.name).length })),
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
app.get('/api/collections/:name', (req, res) => {
|
|
128
|
+
const auth = requireApiAuth(req, res);
|
|
129
|
+
if (!auth)
|
|
130
|
+
return;
|
|
131
|
+
const name = req.params.name;
|
|
132
|
+
let isDir = false;
|
|
133
|
+
try {
|
|
134
|
+
isDir = fs.statSync((0, layout_1.collectionDir)(root, name)).isDirectory();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
isDir = false;
|
|
138
|
+
}
|
|
139
|
+
if (!(0, scan_1.isValidName)(name) || !isDir) {
|
|
140
|
+
(0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
res.json({ name, owners: (0, perms_1.collectionOwners)(root, name), repos: visibleRepos(auth, name).map(scan_1.displayName) });
|
|
144
|
+
});
|
|
145
|
+
app.post('/api/collections', (req, res) => {
|
|
146
|
+
const auth = requireApiAuth(req, res);
|
|
147
|
+
if (!auth)
|
|
148
|
+
return;
|
|
149
|
+
const body = (req.body ?? {});
|
|
150
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
151
|
+
if (!(0, scan_1.isValidName)(name)) {
|
|
152
|
+
(0, auth_1.apiError)(res, 400, 'a valid "name" is required (letters, digits, dot, underscore, dash; not a reserved word)');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!(0, perms_1.canCreateCollection)(root, auth, name)) {
|
|
156
|
+
(0, auth_1.apiError)(res, 403, `only a site admin can create a collection not named after you`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
ops.createCollection(root, name);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
if (e instanceof ops_1.OpError) {
|
|
164
|
+
(0, auth_1.apiError)(res, (0, ops_1.opErrorStatus)(e.kind), e.message);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
res.json({ name, created: true });
|
|
170
|
+
});
|
|
171
|
+
// ---- collection owners ----
|
|
172
|
+
// Owners manage the owners list, and site admins do; the same rule the
|
|
173
|
+
// collection's settings page applies.
|
|
174
|
+
app.put('/api/collections/:name/owners/:user', (req, res) => {
|
|
175
|
+
const auth = requireApiAuth(req, res);
|
|
176
|
+
if (!auth)
|
|
177
|
+
return;
|
|
178
|
+
const name = req.params.name;
|
|
179
|
+
const username = req.params.user;
|
|
180
|
+
if (!(0, scan_1.isValidName)(name) || !fs.existsSync((0, layout_1.collectionDir)(root, name))) {
|
|
181
|
+
(0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
|
|
185
|
+
(0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const state = (0, vault_1.loadVault)(root);
|
|
189
|
+
if (state.status !== 'ok' || !state.vault.users[username]) {
|
|
190
|
+
(0, auth_1.apiError)(res, 404, `no user ${username} in this vault`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (username === name) {
|
|
194
|
+
res.json({ name, owners: (0, perms_1.collectionOwners)(root, name), note: `${username} owns ${name} by name already` });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
(0, perms_1.addCollectionOwner)(root, name, username);
|
|
198
|
+
res.json({ name, owners: (0, perms_1.collectionOwners)(root, name) });
|
|
199
|
+
});
|
|
200
|
+
app.delete('/api/collections/:name/owners/:user', (req, res) => {
|
|
201
|
+
const auth = requireApiAuth(req, res);
|
|
202
|
+
if (!auth)
|
|
203
|
+
return;
|
|
204
|
+
const name = req.params.name;
|
|
205
|
+
const username = req.params.user;
|
|
206
|
+
if (!(0, scan_1.isValidName)(name) || !fs.existsSync((0, layout_1.collectionDir)(root, name))) {
|
|
207
|
+
(0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
|
|
211
|
+
(0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!(0, perms_1.collectionOwners)(root, name).includes(username)) {
|
|
215
|
+
(0, auth_1.apiError)(res, 404, `${username} is not an explicit owner of ${name}`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
(0, perms_1.removeCollectionOwner)(root, name, username);
|
|
219
|
+
res.json({ name, owners: (0, perms_1.collectionOwners)(root, name) });
|
|
220
|
+
});
|
|
221
|
+
// Users are the site admin's business, as they are on GitHub: creating one,
|
|
222
|
+
// listing them, and the site-admin bit itself. What a user may reach is not
|
|
223
|
+
// set here at all; it lives with the collections and repositories that
|
|
224
|
+
// grant it.
|
|
225
|
+
app.get('/api/users', (req, res) => {
|
|
226
|
+
const auth = requireApiAuth(req, res);
|
|
227
|
+
if (!auth)
|
|
228
|
+
return;
|
|
229
|
+
if (!(0, perms_1.isSiteAdmin)(auth)) {
|
|
230
|
+
(0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const state = (0, vault_1.loadVault)(root);
|
|
234
|
+
if (state.status !== 'ok') {
|
|
235
|
+
(0, auth_1.apiError)(res, 500, 'vault unavailable');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
res.json({
|
|
239
|
+
users: Object.entries(state.vault.users).map(([name, u]) => ({
|
|
240
|
+
name,
|
|
241
|
+
siteAdmin: u.siteAdmin === true,
|
|
242
|
+
tokens: u.tokens.length,
|
|
243
|
+
})),
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
app.post('/api/users', (req, res) => {
|
|
247
|
+
const auth = requireApiAuth(req, res);
|
|
248
|
+
if (!auth)
|
|
249
|
+
return;
|
|
250
|
+
const body = (req.body ?? {});
|
|
251
|
+
const username = typeof body.username === 'string' ? body.username : '';
|
|
252
|
+
if (!(0, scan_1.isValidUserName)(username)) {
|
|
253
|
+
(0, auth_1.apiError)(res, 400, 'a valid "username" is required');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const tokenScope = sanitizeGlobs(body.tokenScope);
|
|
257
|
+
if (tokenScope === null) {
|
|
258
|
+
(0, auth_1.apiError)(res, 400, '"tokenScope" must be a list of strings');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Refused loudly rather than ignored: an older client sending the glob
|
|
262
|
+
// fields would otherwise create a user without the access its operator
|
|
263
|
+
// asked for, and silence is the worst way to deliver that.
|
|
264
|
+
if (body.scope !== undefined || body.admin !== undefined) {
|
|
265
|
+
(0, auth_1.apiError)(res, 400, '"scope" and "admin" are gone: a user owns the collection named after them, and anything more is ' +
|
|
266
|
+
'granted where it applies (repository collaborators, collection owners) or with "siteAdmin"');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (body.siteAdmin !== undefined && typeof body.siteAdmin !== 'boolean') {
|
|
270
|
+
(0, auth_1.apiError)(res, 400, '"siteAdmin" must be a boolean');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!(0, perms_1.isSiteAdmin)(auth)) {
|
|
274
|
+
(0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const state = (0, vault_1.loadVault)(root);
|
|
278
|
+
if (state.status !== 'ok') {
|
|
279
|
+
(0, auth_1.apiError)(res, 500, 'vault unavailable');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const existing = state.vault.users[username];
|
|
283
|
+
if (existing && body.siteAdmin !== undefined) {
|
|
284
|
+
(0, auth_1.apiError)(res, 409, `user ${username} already exists; use 'mochi user grant' to change the site-admin bit`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const result = (0, vault_1.addUserToken)(root, username, {
|
|
288
|
+
siteAdmin: body.siteAdmin === true,
|
|
289
|
+
tokenScope: tokenScope ?? undefined,
|
|
290
|
+
});
|
|
291
|
+
res.json({
|
|
292
|
+
username,
|
|
293
|
+
created: result.created,
|
|
294
|
+
token: result.token,
|
|
295
|
+
siteAdmin: result.user.siteAdmin === true,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
app.post('/api/users/:name/grant', (req, res) => {
|
|
299
|
+
const auth = requireApiAuth(req, res);
|
|
300
|
+
if (!auth)
|
|
301
|
+
return;
|
|
302
|
+
const username = req.params.name;
|
|
303
|
+
if (!(0, scan_1.isValidName)(username)) {
|
|
304
|
+
(0, auth_1.apiError)(res, 400, 'invalid username');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const body = (req.body ?? {});
|
|
308
|
+
if (typeof body.siteAdmin !== 'boolean') {
|
|
309
|
+
(0, auth_1.apiError)(res, 400, 'provide "siteAdmin": true or false; per-repository access is granted on the repository (collaborators) or the collection (owners)');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!(0, perms_1.isSiteAdmin)(auth)) {
|
|
313
|
+
(0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let user;
|
|
317
|
+
try {
|
|
318
|
+
user = (0, vault_1.setSiteAdmin)(root, username, body.siteAdmin);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
(0, auth_1.apiError)(res, 404, e instanceof Error ? e.message : String(e));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
res.json({ username, siteAdmin: user.siteAdmin === true });
|
|
325
|
+
});
|
|
326
|
+
}
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.styleSheet = styleSheet;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const style_1 = require("./style");
|
|
6
|
+
const themes_1 = require("./themes");
|
|
7
|
+
/**
|
|
8
|
+
* The forge's stylesheet, with a tag naming this exact body.
|
|
9
|
+
*
|
|
10
|
+
* The sheet is 76 KB and every page links it, so what it costs is decided by
|
|
11
|
+
* whether the browser may keep it. It used to say `no-cache`, which bought a
|
|
12
|
+
* conditional request on every navigation -- 304 at best, and a round trip
|
|
13
|
+
* before anything could be painted. The tag ends that: it is a hash of the
|
|
14
|
+
* bytes, so a changed theme or an edited style.ts changes the URL, and any URL
|
|
15
|
+
* that carries the right tag can be kept for good.
|
|
16
|
+
*
|
|
17
|
+
* Built once per theme rather than per request, which also stops the 76 KB
|
|
18
|
+
* concatenation from happening on every hit.
|
|
19
|
+
*/
|
|
20
|
+
const sheets = new Map();
|
|
21
|
+
function styleSheet(theme) {
|
|
22
|
+
const made = sheets.get(theme.name);
|
|
23
|
+
if (made)
|
|
24
|
+
return made;
|
|
25
|
+
const body = (0, themes_1.allThemeVarsCss)(theme) + style_1.CSS;
|
|
26
|
+
const sheet = { body, tag: (0, crypto_1.createHash)('sha256').update(body).digest('hex').slice(0, 12) };
|
|
27
|
+
sheets.set(theme.name, sheet);
|
|
28
|
+
return sheet;
|
|
29
|
+
}
|
package/dist/atom.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.atomFeed = atomFeed;
|
|
4
|
+
const render_1 = require("./render");
|
|
5
|
+
function iso(date) {
|
|
6
|
+
const d = new Date(date);
|
|
7
|
+
return isNaN(d.getTime()) ? new Date(0).toISOString() : d.toISOString();
|
|
8
|
+
}
|
|
9
|
+
function atomFeed(feed) {
|
|
10
|
+
// The feed's own timestamp is that of its newest entry, and the epoch when
|
|
11
|
+
// it has none, so a reader can tell a quiet feed from a broken one.
|
|
12
|
+
const updated = feed.entries.length ? iso(feed.entries[0].updated) : new Date(0).toISOString();
|
|
13
|
+
const entries = feed.entries
|
|
14
|
+
.map((e) => ` <entry>
|
|
15
|
+
<id>${(0, render_1.esc)(e.id)}</id>
|
|
16
|
+
<title>${(0, render_1.esc)(e.title)}</title>
|
|
17
|
+
<updated>${(0, render_1.esc)(iso(e.updated))}</updated>
|
|
18
|
+
<link rel="alternate" type="text/html" href="${(0, render_1.esc)(e.link)}"/>
|
|
19
|
+
<author><name>${(0, render_1.esc)(e.author)}</name></author>${e.summary ? `\n <content type="text">${(0, render_1.esc)(e.summary)}</content>` : ''}
|
|
20
|
+
</entry>`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
23
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
24
|
+
<id>${(0, render_1.esc)(feed.id)}</id>
|
|
25
|
+
<title>${(0, render_1.esc)(feed.title)}</title>
|
|
26
|
+
<updated>${(0, render_1.esc)(updated)}</updated>
|
|
27
|
+
<link rel="self" type="application/atom+xml" href="${(0, render_1.esc)(feed.selfLink)}"/>
|
|
28
|
+
<link rel="alternate" type="text/html" href="${(0, render_1.esc)(feed.htmlLink)}"/>
|
|
29
|
+
${entries}
|
|
30
|
+
</feed>
|
|
31
|
+
`;
|
|
32
|
+
}
|
package/dist/atomic.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.writeFileAtomic = writeFileAtomic;
|
|
37
|
+
exports.withFileLock = withFileLock;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
/**
|
|
41
|
+
* Replace a file's contents in one step, durably.
|
|
42
|
+
*
|
|
43
|
+
* A vault is a directory of plain files and nothing else, so the files that
|
|
44
|
+
* hold its state are the state. Six places wrote one of them by the same
|
|
45
|
+
* three lines, writing a sibling `.tmp` and renaming it over the target, which
|
|
46
|
+
* is what stops a reader from seeing half a file: a rename within a directory
|
|
47
|
+
* is atomic, so a concurrent read sees the old contents or the new ones.
|
|
48
|
+
*
|
|
49
|
+
* What those six lines did not do is reach the disk. A rename can be recorded
|
|
50
|
+
* before the data the renamed file points at, so a machine that loses power at
|
|
51
|
+
* the wrong moment can come back to a `vault.json` that exists, parses as
|
|
52
|
+
* nothing, and locks every user out of a vault whose repositories are all
|
|
53
|
+
* still there. That is the one failure here worth paying two fsyncs to avoid:
|
|
54
|
+
* one on the contents before the rename, and one on the directory afterwards,
|
|
55
|
+
* which is what makes the rename itself survive.
|
|
56
|
+
*
|
|
57
|
+
* The temporary name carries the process id, so two servers pointed at one
|
|
58
|
+
* vault cannot write over each other's half-written file. It does not by itself
|
|
59
|
+
* make the larger read-modify-write safe; `withFileLock` below is what does
|
|
60
|
+
* that, and a caller that reads a state file, changes it, and writes it back
|
|
61
|
+
* belongs inside one.
|
|
62
|
+
*/
|
|
63
|
+
function writeFileAtomic(file, data, opts = {}) {
|
|
64
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
65
|
+
const fd = fs.openSync(tmp, 'w', opts.mode ?? 0o666);
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(fd, data);
|
|
68
|
+
fs.fsyncSync(fd);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
fs.closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
fs.renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
fs.rmSync(tmp, { force: true });
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
// The rename is what has to survive, and it lives in the directory rather
|
|
81
|
+
// than in either file. Not every platform lets a directory be opened, and a
|
|
82
|
+
// vault on one that does not is better served slightly less durably than not
|
|
83
|
+
// at all, so this is attempted rather than required.
|
|
84
|
+
let dir = null;
|
|
85
|
+
try {
|
|
86
|
+
dir = fs.openSync(path.dirname(file), 'r');
|
|
87
|
+
fs.fsyncSync(dir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// no directory handle to sync on this platform
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (dir !== null)
|
|
94
|
+
fs.closeSync(dir);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// How long a lock may be held before another holder assumes its owner died.
|
|
98
|
+
// The critical sections here are a small read, an edit in memory, and a write,
|
|
99
|
+
// so anything approaching this is a crashed process rather than a slow one.
|
|
100
|
+
const LOCK_STALE_MS = 10 * 1000;
|
|
101
|
+
// How long to wait for a lock before giving up. Well beyond any honest holder,
|
|
102
|
+
// and beyond the staleness threshold, so a wait that ends in a throw means
|
|
103
|
+
// something is wrong rather than merely busy.
|
|
104
|
+
const LOCK_TIMEOUT_MS = 15 * 1000;
|
|
105
|
+
const LOCK_RETRY_MS = 20;
|
|
106
|
+
/** Sleep without yielding, which is what a synchronous critical section needs. */
|
|
107
|
+
function sleepSync(ms) {
|
|
108
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run `fn` while holding an exclusive lock on `lockPath`, so that a read, an
|
|
112
|
+
* edit, and a write back are one operation against other processes as well as
|
|
113
|
+
* against other requests.
|
|
114
|
+
*
|
|
115
|
+
* The state files at a vault's root are read-modify-written by whoever has the
|
|
116
|
+
* directory: the server answering `POST /api/users`, and `mochi user add`
|
|
117
|
+
* run against the same path from a shell. Both were reading, editing in memory,
|
|
118
|
+
* and writing the whole file back, so two of them overlapping lost one edit
|
|
119
|
+
* entirely - a user created and then gone, or a token revoked and then back.
|
|
120
|
+
* Node's single thread makes each of these safe within one process, since none
|
|
121
|
+
* of them awaits; it says nothing about two.
|
|
122
|
+
*
|
|
123
|
+
* `open(O_CREAT|O_EXCL)` is the lock: it is one atomic step on every platform
|
|
124
|
+
* this runs on, and it needs no dependency. A lock older than LOCK_STALE_MS is
|
|
125
|
+
* taken to belong to a process that died holding it and is broken, because the
|
|
126
|
+
* alternative is a vault that cannot be administered until someone finds a
|
|
127
|
+
* stray file. The lock is advisory in the sense that matters: hand-editing
|
|
128
|
+
* `vault.json` while the server runs still works, exactly as the documentation
|
|
129
|
+
* promises, and is still the operator's own business to time.
|
|
130
|
+
*/
|
|
131
|
+
function withFileLock(lockPath, fn) {
|
|
132
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
133
|
+
let fd = null;
|
|
134
|
+
for (;;) {
|
|
135
|
+
try {
|
|
136
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (e.code !== 'EEXIST')
|
|
141
|
+
throw e;
|
|
142
|
+
let age = 0;
|
|
143
|
+
try {
|
|
144
|
+
age = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// It went away between the open and the stat; just retry.
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (age > LOCK_STALE_MS) {
|
|
151
|
+
fs.rmSync(lockPath, { force: true });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() > deadline) {
|
|
155
|
+
throw new Error(`timed out waiting for the lock at ${lockPath}; remove it if no mochi process is running`);
|
|
156
|
+
}
|
|
157
|
+
sleepSync(LOCK_RETRY_MS);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
fs.writeSync(fd, String(process.pid));
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
fd = null;
|
|
164
|
+
return fn();
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
if (fd !== null)
|
|
168
|
+
fs.closeSync(fd);
|
|
169
|
+
fs.rmSync(lockPath, { force: true });
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/avatar.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Identicons.
|
|
3
|
+
//
|
|
4
|
+
// GitHub puts a face beside every name, and a name without one reads as
|
|
5
|
+
// unfinished next to it. A vault has no uploaded pictures and no gravatar
|
|
6
|
+
// lookups (that would be a request to a third party on every page), so the
|
|
7
|
+
// picture is drawn from the name itself: the same name always draws the same
|
|
8
|
+
// mark, and two different names almost never draw the same one.
|
|
9
|
+
//
|
|
10
|
+
// The drawing is GitHub's own identicon construction. A 5 x 5 grid of cells
|
|
11
|
+
// is filled from the bits of a hash of the name, mirrored about the middle
|
|
12
|
+
// column so the result reads as a face rather than as noise, and painted in a
|
|
13
|
+
// single hue taken from the same hash. Lightness and saturation are fixed at
|
|
14
|
+
// values that hold their contrast against both a near-white and a near-black
|
|
15
|
+
// page, so one drawing serves every theme.
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.identicon = identicon;
|
|
51
|
+
exports.avatar = avatar;
|
|
52
|
+
const crypto = __importStar(require("crypto"));
|
|
53
|
+
const html_1 = require("./html");
|
|
54
|
+
/** The identicon for a name, as an inline SVG of the given pixel size. */
|
|
55
|
+
function identicon(name, size = 20) {
|
|
56
|
+
const hash = crypto.createHash('sha256').update(name.trim().toLowerCase()).digest();
|
|
57
|
+
const hue = Math.round((hash[0] / 256) * 360);
|
|
58
|
+
const fill = `hsl(${hue} 58% 46%)`;
|
|
59
|
+
// Cells are addressed by the left three columns; bit i of the hash body
|
|
60
|
+
// decides cell i, and columns 3 and 4 mirror columns 1 and 0.
|
|
61
|
+
const cells = [];
|
|
62
|
+
for (let col = 0; col < 3; col++) {
|
|
63
|
+
for (let row = 0; row < 5; row++) {
|
|
64
|
+
if ((hash[1 + col * 5 + row] & 1) === 0)
|
|
65
|
+
continue;
|
|
66
|
+
for (const c of col === 2 ? [2] : [col, 4 - col]) {
|
|
67
|
+
cells.push(`<rect x="${c}" y="${row}" width="1" height="1"/>`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (0, html_1.raw)(`<svg class="identicon" width="${size}" height="${size}" viewBox="0 0 5 5" role="img" aria-label="${escapeAttr(name)}" fill="${fill}">${cells.join('')}</svg>`);
|
|
72
|
+
}
|
|
73
|
+
/** The identicon in the frame the interface puts round it. */
|
|
74
|
+
function avatar(name, size = 20, cls = '') {
|
|
75
|
+
return (0, html_1.raw)(`<span class="avatar${cls ? ` ${cls}` : ''}" style="width:${size}px;height:${size}px">${identicon(name, size)}</span>`);
|
|
76
|
+
}
|
|
77
|
+
// Local rather than imported from render.ts: this file is about drawing, and
|
|
78
|
+
// the only thing it interpolates is the name in a label.
|
|
79
|
+
function escapeAttr(s) {
|
|
80
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
81
|
+
}
|