@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.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
package/dist/webops.js ADDED
@@ -0,0 +1,1402 @@
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.registerWebOps = registerWebOps;
40
+ const express_1 = __importDefault(require("express"));
41
+ const fs = __importStar(require("fs"));
42
+ const config_1 = require("./config");
43
+ const git_1 = require("./git");
44
+ const trigger_1 = require("./ci/trigger");
45
+ const layout_1 = require("./layout");
46
+ const pointer_1 = require("./pointer");
47
+ const themes_1 = require("./themes");
48
+ const forms = __importStar(require("./forms"));
49
+ const ops = __importStar(require("./ops"));
50
+ const ops_1 = require("./ops");
51
+ const scan_1 = require("./scan");
52
+ const session_1 = require("./session");
53
+ const perms_1 = require("./perms");
54
+ const vault_1 = require("./vault");
55
+ const views_1 = require("./views");
56
+ const web_1 = require("./web");
57
+ const render_1 = require("./render");
58
+ const multipart_1 = require("./multipart");
59
+ function urlOf(repo) {
60
+ return (0, views_1.repoUrl)({ collection: repo.collection, repo: repo.name });
61
+ }
62
+ // UI operations: every handler here re-derives the actor's abilities from
63
+ // live vault.json (via the session cookie) and checks the CSRF field before
64
+ // calling into the ops layer. All POSTs follow POST-redirect-GET.
65
+ // The largest body here is a file being edited in the browser, and the editor
66
+ // accepts files up to MAX_EDIT_SIZE, so the limit has to leave room for a
67
+ // megabyte of text after percent-encoding.
68
+ const form = (0, web_1.urlencodedForm)('3mb');
69
+ /**
70
+ * The commit box posts a summary and an optional extended description; git's
71
+ * convention joins them with a blank line. An empty summary falls back to the
72
+ * placeholder the form showed, so a commit is never made with no subject.
73
+ */
74
+ function commitMessage(req, fallback) {
75
+ const summary = (0, web_1.field)(req, 'message').trim() || fallback;
76
+ const description = (0, web_1.field)(req, 'description').trim();
77
+ return description ? `${summary}\n\n${description}` : summary;
78
+ }
79
+ /**
80
+ * Where signing in returns to: a path on this vault, or the front page.
81
+ *
82
+ * The sign-in page is the one place in the interface that asks for a token, so
83
+ * it is the one place a redirect elsewhere is worth the most to somebody else.
84
+ * A leading `//` is refused because the browser reads it as an authority, and a
85
+ * leading `/\` for the same reason: the URL standard treats a backslash as a
86
+ * slash for http and https, `Location: /\evil.com` therefore resolves to
87
+ * http://evil.com, and the header carries the character through unencoded. A
88
+ * control character is refused too, since a browser strips tab and newline out
89
+ * of a URL before parsing it, which would turn `/<tab>/evil.com` into the
90
+ * authority the first two checks just refused.
91
+ */
92
+ function safeNext(v) {
93
+ if (!v.startsWith('/'))
94
+ return '/';
95
+ if (v[1] === '/' || v[1] === '\\')
96
+ return '/';
97
+ if (/[\x00-\x1f\x7f]/.test(v))
98
+ return '/';
99
+ return v;
100
+ }
101
+ function normalizeContent(text) {
102
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
103
+ }
104
+ function globsField(req, name) {
105
+ const parts = (0, web_1.field)(req, name)
106
+ .split(/[\s,]+/)
107
+ .filter((s) => s.length > 0);
108
+ if (parts.some((s) => s.length >= 200))
109
+ return null;
110
+ return parts;
111
+ }
112
+ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
113
+ // What a repository move or removal has to carry with it, built once here so
114
+ // that no handler has to remember either half. See RepoContext in ops.ts.
115
+ const repoCtx = { lfs: lfs?.store, runs: engine };
116
+ // A commit made in the browser is a push like any other as far as workflows
117
+ // are concerned, so the same event goes to the CI engine, through the same
118
+ // function the API write paths use.
119
+ const fire = (repo, branch, before, after, actor) => (0, trigger_1.firePush)(root, engine, repo, branch, before, after, actor);
120
+ /**
121
+ * The branch the form asked to commit to, when "commit to a new branch" was
122
+ * ticked. Creating it is ops.writeFile's business; reading the form is this
123
+ * function's, and an empty name means the box was ticked without one.
124
+ */
125
+ function newBranchWanted(req) {
126
+ if ((0, web_1.field)(req, 'newBranchWanted') !== '1')
127
+ return null;
128
+ const wanted = (0, web_1.field)(req, 'newBranch').trim();
129
+ if (wanted === '')
130
+ throw new ops_1.OpError('Name the new branch, or untick the box to commit to this one.');
131
+ return wanted;
132
+ }
133
+ function authorFor(viewer, req) {
134
+ const host = (req.get('host') ?? 'localhost').replace(/:\d+$/, '');
135
+ return { name: viewer.auth.username, email: `${viewer.auth.username}@noreply.${host}` };
136
+ }
137
+ // ---- sign in / sign out ----
138
+ app.get('/login', (req, res) => {
139
+ const next = safeNext(String(req.query.next ?? '/'));
140
+ if ((0, session_1.getViewer)(req, root)) {
141
+ res.redirect(next);
142
+ return;
143
+ }
144
+ res.type('html').send(forms.loginPage(next));
145
+ });
146
+ app.post('/login', form, (req, res) => {
147
+ const next = safeNext((0, web_1.field)(req, 'next'));
148
+ const state = (0, vault_1.loadVault)(root);
149
+ if (state.status !== 'ok') {
150
+ res.status(500).type('html').send(forms.loginPage(next, 'The vault is not available; try again later.'));
151
+ return;
152
+ }
153
+ const username = (0, web_1.field)(req, 'username');
154
+ // Throttled per address, and per address and username together, so that one
155
+ // person mistyping a token does not lock out everyone behind the same
156
+ // address. Never per account: anyone could then lock an owner out by
157
+ // presenting wrong tokens for their username.
158
+ const allowed = authLimiter.allow(req, username);
159
+ if (!allowed.ok) {
160
+ const minutes = Math.max(1, Math.ceil(allowed.retryAfter / 60));
161
+ res.status(429).setHeader('Retry-After', String(allowed.retryAfter));
162
+ res
163
+ .type('html')
164
+ .send(forms.loginPage(next, `Too many failed sign-in attempts from this address. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`));
165
+ return;
166
+ }
167
+ const auth = (0, vault_1.authenticate)(state.vault, username, (0, web_1.field)(req, 'token'));
168
+ if (!auth) {
169
+ authLimiter.fail(req, username);
170
+ // One generic message: no username/token distinction. The refusal above
171
+ // says nothing about whether the username exists either.
172
+ res.status(401).type('html').send(forms.loginPage(next, 'Invalid username or token.'));
173
+ return;
174
+ }
175
+ (0, session_1.setSessionCookie)(req, res, root, auth);
176
+ res.redirect(next);
177
+ });
178
+ app.post('/logout', form, (req, res) => {
179
+ const viewer = (0, session_1.getViewer)(req, root);
180
+ if (viewer && (0, session_1.checkCsrf)(req, viewer))
181
+ (0, session_1.clearSessionCookie)(res);
182
+ res.redirect('/');
183
+ });
184
+ // ---- new repository, new collection, import ----
185
+ // Importing is a client-side operation: git copies the repository from its
186
+ // current home and pushes it here, where push-to-create makes it. The server
187
+ // never fetches from another host, so it needs no outbound access, no stored
188
+ // credentials for other services, and no work that outlives a request. What
189
+ // performs it is `mochi import` on the operator's machine, and this page
190
+ // only says what to run: no form, since there is nothing here to submit to.
191
+ app.get('/import', (req, res) => {
192
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
193
+ if (!viewer)
194
+ return;
195
+ const asked = typeof req.query.collection === 'string' ? req.query.collection.trim() : '';
196
+ const collection = (0, scan_1.isValidName)(asked) ? asked : null;
197
+ // Nothing here performs the import, but a page that hands someone a command
198
+ // their token cannot run is worse than a refusal, so the collection they
199
+ // asked about is checked against their scope before it is written into one.
200
+ if (collection && !(0, perms_1.canCreateSomeRepo)(root, viewer.auth, collection)) {
201
+ (0, web_1.fail)(res, 403, `You are not allowed to create repositories in ${collection}, so the push would be refused.`, viewer, `/${encodeURIComponent(collection)}`);
202
+ return;
203
+ }
204
+ const host = req.get('host') ?? '';
205
+ const vaultUrl = `${req.protocol}://${host}`;
206
+ // The git fallback ends up on a command line, so it is written only when
207
+ // the host name and the username carry nothing a shell would read.
208
+ const plain = /^[A-Za-z0-9.:_-]+$/.test(host) && /^[A-Za-z0-9._-]+$/.test(viewer.auth.username);
209
+ // The clone is a scratch copy, so it goes to a temporary directory rather
210
+ // than to whatever directory the command happens to be pasted into. A
211
+ // fresh one each time means a failed attempt never blocks the next, and
212
+ // what it leaves behind is under /tmp rather than in someone's work tree.
213
+ // mktemp is given an explicit template because plain `mktemp -d` is a
214
+ // usage error on BSD, and `-t prefix` is a usage error on GNU.
215
+ //
216
+ // GIT_ASKPASS= on the push keeps the password prompt in the terminal the
217
+ // command was pasted into. Editors that set GIT_ASKPASS (VS Code does for
218
+ // its integrated terminal) otherwise redirect it to a dialog elsewhere in
219
+ // the window, and an unanswered dialog looks exactly like a hang: git
220
+ // prints nothing after the clone and waits. Credential helpers are
221
+ // consulted before askpass, so a stored credential still works.
222
+ const dest = `${req.protocol}://${encodeURIComponent(viewer.auth.username)}@${host}/${encodeURIComponent(collection ?? 'mycollection')}/REPO-NAME`;
223
+ const gitCommand = plain
224
+ ? `tmp="$(mktemp -d /tmp/import.XXXXXX)"` +
225
+ ` && git clone --bare SOURCE-URL "$tmp"` +
226
+ ` && GIT_ASKPASS= git -C "$tmp" push --mirror ${dest}` +
227
+ ` && rm -rf "$tmp"`
228
+ : null;
229
+ res.type('html').send(forms.importPage(viewer, { collection, vaultUrl, gitCommand }));
230
+ });
231
+ // A collection with nothing in it yet. Creating a repository or pushing to a
232
+ // new path creates its collection on the way, so this is the other order:
233
+ // the collection first, filled by an import or a push afterwards.
234
+ app.get('/new/collection', (req, res) => {
235
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
236
+ if (!viewer)
237
+ return;
238
+ res.type('html').send(forms.newCollectionPage(viewer, {}));
239
+ });
240
+ app.post('/new/collection', form, (req, res) => {
241
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
242
+ if (!viewer)
243
+ return;
244
+ const name = (0, web_1.field)(req, 'name').trim();
245
+ const rerender = (status, error) => {
246
+ res.status(status).type('html').send(forms.newCollectionPage(viewer, { name }, error));
247
+ };
248
+ if (!(0, scan_1.isValidName)(name)) {
249
+ rerender(400, 'A collection name may use letters, digits, dot, underscore, and dash, and must not be a reserved word.');
250
+ return;
251
+ }
252
+ if (!(0, perms_1.canCreateCollection)(root, viewer.auth, name)) {
253
+ rerender(403, 'A collection not named after you takes a site admin to create.');
254
+ return;
255
+ }
256
+ try {
257
+ ops.createCollection(root, name);
258
+ }
259
+ catch (e) {
260
+ if (e instanceof ops_1.OpError) {
261
+ rerender((0, ops_1.opErrorStatus)(e.kind), e.message);
262
+ return;
263
+ }
264
+ throw e;
265
+ }
266
+ res.redirect(`/${encodeURIComponent(name)}`);
267
+ });
268
+ // ---- a collection's own settings ----
269
+ //
270
+ // These sit at /:collection/settings, one segment shallower than a
271
+ // repository's, and are registered here rather than in browse.ts so that
272
+ // they are matched ahead of /:collection/:repo. Nothing is shadowed by
273
+ // them: `settings` is a reserved name, so no repository can be called it.
274
+ /**
275
+ * The collection named in the path, with the repositories it holds, or null
276
+ * once a response has been sent. Renaming needs the repository names twice
277
+ * over - to decide the actor's abilities, and to say in the page what moves
278
+ * with the collection - so both handlers start here.
279
+ */
280
+ function loadCollection(req, res, viewer, post) {
281
+ const name = req.params.collection;
282
+ let isDir = false;
283
+ try {
284
+ isDir = fs.statSync((0, layout_1.collectionDir)(root, name)).isDirectory();
285
+ }
286
+ catch {
287
+ isDir = false;
288
+ }
289
+ if (!(0, scan_1.isValidName)(name) || !isDir) {
290
+ if (post)
291
+ (0, web_1.fail)(res, 404, `Collection ${name} not found`, viewer, '/');
292
+ else
293
+ (0, web_1.send404)(res, `Collection ${name} not found`, viewer);
294
+ return null;
295
+ }
296
+ const repos = (0, scan_1.listRepoDirs)(root, name).map(scan_1.displayName);
297
+ if (!(0, perms_1.canAdminCollection)(root, viewer.auth, name)) {
298
+ (0, web_1.fail)(res, 403, `Collection settings are for the owners of ${name}.`, viewer, `/${encodeURIComponent(name)}`);
299
+ return null;
300
+ }
301
+ return { name, repos };
302
+ }
303
+ app.get('/:collection/settings', (req, res) => {
304
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
305
+ if (!viewer)
306
+ return;
307
+ const loaded = loadCollection(req, res, viewer, false);
308
+ if (!loaded)
309
+ return;
310
+ const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
311
+ res
312
+ .type('html')
313
+ .send(forms.collectionSettingsPage(viewer, loaded.name, loaded.repos.length, (0, perms_1.collectionOwners)(root, loaded.name), msg));
314
+ });
315
+ app.post('/:collection/settings/rename', form, (0, web_1.ah)(async (req, res) => {
316
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
317
+ if (!viewer)
318
+ return;
319
+ const loaded = loadCollection(req, res, viewer, true);
320
+ if (!loaded)
321
+ return;
322
+ const from = loaded.name;
323
+ const backUrl = `/${encodeURIComponent(from)}/settings`;
324
+ const toName = (0, web_1.field)(req, 'name').trim();
325
+ // Ownership, which loadCollection has already established, is the whole
326
+ // permission: the owners file moves with the collection, so the owners
327
+ // after the rename are the owners before it. Whether the new name is
328
+ // free is the operation's own check.
329
+ if (!(0, scan_1.isValidName)(toName)) {
330
+ (0, web_1.fail)(res, 400, 'A collection name may use letters, digits, dot, underscore, and dash, and must not be a reserved word.', viewer, backUrl);
331
+ return;
332
+ }
333
+ try {
334
+ await ops.renameCollection(root, from, toName, repoCtx);
335
+ }
336
+ catch (e) {
337
+ const message = e instanceof ops_1.OpError ? e.message : 'Could not rename the collection.';
338
+ (0, web_1.fail)(res, e instanceof ops_1.OpError ? (0, ops_1.opErrorStatus)(e.kind) : 400, message, viewer, backUrl);
339
+ return;
340
+ }
341
+ res.redirect(`/${encodeURIComponent(toName)}/settings?msg=${encodeURIComponent(`Renamed from ${from}.`)}`);
342
+ }));
343
+ // Owners manage the owners list, which loadCollection has established. The
344
+ // implicit owner (the user the collection is named after) is not on the
345
+ // list and cannot be removed from it.
346
+ app.post('/:collection/settings/owners', form, (req, res) => {
347
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
348
+ if (!viewer)
349
+ return;
350
+ const loaded = loadCollection(req, res, viewer, true);
351
+ if (!loaded)
352
+ return;
353
+ const backUrl = `/${encodeURIComponent(loaded.name)}/settings`;
354
+ const username = (0, web_1.field)(req, 'username').trim();
355
+ const state = (0, vault_1.loadVault)(root);
356
+ if (state.status !== 'ok' || !state.vault.users[username]) {
357
+ (0, web_1.fail)(res, 404, `No user ${username || '(no name given)'} in this vault.`, viewer, backUrl);
358
+ return;
359
+ }
360
+ if (username === loaded.name) {
361
+ (0, web_1.fail)(res, 400, `${username} owns ${loaded.name} by bearing its name already.`, viewer, backUrl);
362
+ return;
363
+ }
364
+ (0, perms_1.addCollectionOwner)(root, loaded.name, username);
365
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(`${username} is now an owner of ${loaded.name}.`)}`);
366
+ });
367
+ app.post('/:collection/settings/owners/remove', form, (req, res) => {
368
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
369
+ if (!viewer)
370
+ return;
371
+ const loaded = loadCollection(req, res, viewer, true);
372
+ if (!loaded)
373
+ return;
374
+ const backUrl = `/${encodeURIComponent(loaded.name)}/settings`;
375
+ const username = (0, web_1.field)(req, 'username').trim();
376
+ if (!(0, perms_1.collectionOwners)(root, loaded.name).includes(username)) {
377
+ (0, web_1.fail)(res, 404, `${username} is not an explicit owner of ${loaded.name}.`, viewer, backUrl);
378
+ return;
379
+ }
380
+ (0, perms_1.removeCollectionOwner)(root, loaded.name, username);
381
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(`Removed ${username} from the owners of ${loaded.name}.`)}`);
382
+ });
383
+ app.get('/new', (req, res) => {
384
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
385
+ if (!viewer)
386
+ return;
387
+ const collections = (0, scan_1.listCollections)(root).map((o) => o.name);
388
+ const collection = typeof req.query.collection === 'string' ? req.query.collection : '';
389
+ // A name may be suggested too, which is how the prompt to write a
390
+ // collection's profile README arrives here with .mochi already filled
391
+ // in (see src/profile.ts).
392
+ const name = typeof req.query.name === 'string' ? req.query.name : '';
393
+ res.type('html').send(forms.newRepoPage(viewer, collections, { collection, name }));
394
+ });
395
+ app.post('/new', form, (0, web_1.ah)(async (req, res) => {
396
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
397
+ if (!viewer)
398
+ return;
399
+ const collection = (0, web_1.field)(req, 'collection').trim();
400
+ const name = (0, web_1.field)(req, 'name').trim();
401
+ const description = (0, web_1.field)(req, 'description').trim();
402
+ const collections = (0, scan_1.listCollections)(root).map((o) => o.name);
403
+ const rerender = (status, error) => {
404
+ res
405
+ .status(status)
406
+ .type('html')
407
+ .send(forms.newRepoPage(viewer, collections, { collection, name, description }, error));
408
+ };
409
+ if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(name)) {
410
+ rerender(400, 'Collection and repository names may use letters, digits, dot, underscore, and dash, and must not be reserved words. Only a repository may begin with a dot.');
411
+ return;
412
+ }
413
+ const reserved = (0, scan_1.reservedRepoSuffix)(name);
414
+ if (reserved) {
415
+ rerender(400, `A repository name may not end in ${reserved}, which is reserved for the directories a repository keeps beside it.`);
416
+ return;
417
+ }
418
+ if (!(0, perms_1.canCreateRepo)(root, viewer.auth, collection, name)) {
419
+ rerender(403, `You are not allowed to create repositories in ${collection}.`);
420
+ return;
421
+ }
422
+ if ((0, scan_1.findRepo)(root, collection, name)) {
423
+ rerender(409, `Repository ${collection}/${name} already exists.`);
424
+ return;
425
+ }
426
+ // The checks above answer every refusal this form can provoke, so the
427
+ // ops layer's own are a backstop. Rendered rather than thrown all the
428
+ // same: a backstop that reaches the reader as a 500 is one that says the
429
+ // server broke when what happened is that it declined.
430
+ let created;
431
+ try {
432
+ created = await ops.createRepoWithReadme(root, collection, name, {
433
+ description,
434
+ readme: (0, web_1.field)(req, 'init') === '1',
435
+ private: (0, web_1.field)(req, 'private') === '1',
436
+ author: authorFor(viewer, req),
437
+ });
438
+ }
439
+ catch (e) {
440
+ if (e instanceof ops_1.OpError) {
441
+ rerender((0, ops_1.opErrorStatus)(e.kind), e.message);
442
+ return;
443
+ }
444
+ throw e;
445
+ }
446
+ if (created.sha)
447
+ fire(created.repo, 'main', null, created.sha, viewer.auth.username);
448
+ res.redirect(`/${encodeURIComponent(collection)}/${encodeURIComponent(name)}`);
449
+ }));
450
+ // Resolves an /:collection/:repo/<verb>/<branch>/<path> URL and enforces what all
451
+ // file operations share: the ref must be a branch (or the repository must
452
+ // be empty) and the viewer needs the write role on the repository.
453
+ async function loadFileTarget(req, res, viewer, opts) {
454
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
455
+ if (!loaded)
456
+ return null;
457
+ const { ref, path: filePath } = loaded.repo.resolveRefAndPath((0, web_1.wildcard)(req), loaded.refNames);
458
+ if (!(0, git_1.isValidRepoPath)(filePath)) {
459
+ (0, web_1.send404)(res, 'Not found', viewer);
460
+ return null;
461
+ }
462
+ if (!(0, perms_1.atLeast)(loaded.role, 'write')) {
463
+ (0, web_1.fail)(res, 403, `You do not have the write role on ${loaded.repo.collection}/${loaded.repo.name}.`, viewer, urlOf(loaded.repo));
464
+ return null;
465
+ }
466
+ const branchInfo = loaded.branches.find((b) => b.name === ref);
467
+ if (!branchInfo) {
468
+ if (opts.allowEmptyRepo && loaded.branches.length === 0 && (0, git_1.isValidRefName)(ref) && !ref.startsWith('-')) {
469
+ return { loaded, branch: ref, filePath, tip: null };
470
+ }
471
+ (0, web_1.fail)(res, 400, 'Files can only be changed on a branch. Switch to a branch and try again.', viewer, urlOf(loaded.repo));
472
+ return null;
473
+ }
474
+ return { loaded, branch: ref, filePath, tip: branchInfo.sha };
475
+ }
476
+ async function handleOpError(e, req, res, viewer, target, retryUrl) {
477
+ if (e instanceof ops_1.OpError && e.kind === 'conflict') {
478
+ const ctx = await (0, web_1.makeCtx)(root, req, target.loaded, target.branch, viewer);
479
+ res.status(409).type('html').send(forms.conflictPage(ctx, target.branch, retryUrl));
480
+ return;
481
+ }
482
+ // A branch that could not be made is not a file that could not be written:
483
+ // a name already taken is somebody else's branch, and 409 says so.
484
+ if (e instanceof ops.NewBranchError) {
485
+ (0, web_1.fail)(res, (0, ops_1.opErrorStatus)(e.kind), e.message, viewer, retryUrl);
486
+ return;
487
+ }
488
+ if (e instanceof ops_1.OpError) {
489
+ (0, web_1.fail)(res, (0, ops_1.opErrorStatus)(e.kind), e.message, viewer, retryUrl);
490
+ return;
491
+ }
492
+ throw e;
493
+ }
494
+ app.get('/:collection/:repo/edit/*', (0, web_1.ah)(async (req, res) => {
495
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
496
+ if (!viewer)
497
+ return;
498
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: false });
499
+ if (!target)
500
+ return;
501
+ const { loaded, branch, filePath } = target;
502
+ const type = await loaded.repo.entryType(branch, filePath);
503
+ if (type !== 'blob') {
504
+ (0, web_1.send404)(res, `File ${filePath} not found at ${branch}`, viewer);
505
+ return;
506
+ }
507
+ const buf = await loaded.repo.catBlob(branch, filePath);
508
+ // Editing a pointer as text would silently corrupt the repository's
509
+ // LFS state, so it is refused outright (deletion remains allowed).
510
+ if ((0, pointer_1.looksLikePointer)(buf)) {
511
+ (0, web_1.fail)(res, 400, 'This file is stored with Git LFS; the repository holds only a pointer to it. Change the file with a git client instead.', viewer, urlOf(loaded.repo));
512
+ return;
513
+ }
514
+ if ((0, render_1.isBinary)(buf) || buf.length > ops_1.MAX_EDIT_SIZE) {
515
+ (0, web_1.fail)(res, 400, 'Only text files up to 1 MB can be edited in the browser.', viewer, urlOf(loaded.repo));
516
+ return;
517
+ }
518
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, branch, viewer);
519
+ res.type('html').send(forms.editFilePage(ctx, filePath, buf.toString('utf8'), target.tip));
520
+ }));
521
+ app.post('/:collection/:repo/edit/*', form, (0, web_1.ah)(async (req, res) => {
522
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
523
+ if (!viewer)
524
+ return;
525
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: false });
526
+ if (!target)
527
+ return;
528
+ const { loaded, branch, filePath } = target;
529
+ const expected = (0, web_1.field)(req, 'expected');
530
+ if (!(0, git_1.isValidSha)(expected)) {
531
+ (0, web_1.fail)(res, 400, 'The form is missing its base commit; reload and try again.', viewer);
532
+ return;
533
+ }
534
+ const content = normalizeContent((0, web_1.field)(req, 'content'));
535
+ // A changed path renames or moves the file in the same commit, which is
536
+ // what the path field on the form is for.
537
+ const wantedPath = (0, web_1.field)(req, 'path').trim().replace(/^\/+/, '');
538
+ const toPath = wantedPath === '' || wantedPath === filePath ? undefined : wantedPath;
539
+ const retryUrl = `${urlOf(loaded.repo)}/edit/${(0, views_1.encPath)(branch)}/${(0, views_1.encPath)(filePath)}`;
540
+ if (toPath !== undefined && !(0, git_1.isValidRepoPath)(toPath)) {
541
+ (0, web_1.fail)(res, 400, `${toPath} is not a usable path.`, viewer, retryUrl);
542
+ return;
543
+ }
544
+ const message = commitMessage(req, toPath === undefined ? `Update ${filePath.split('/').pop()}` : `Rename ${filePath} to ${toPath}`);
545
+ // The branch choice, the pointer refusal, and the guarded commit are one
546
+ // sequence in ops.writeFile, which the API write path calls too. The GET
547
+ // form already refuses a pointer file; writeFile re-checks, so the refusal
548
+ // cannot be bypassed by posting directly.
549
+ let written;
550
+ try {
551
+ written = await ops.writeFile(loaded.repo.dir, {
552
+ branch,
553
+ newBranch: newBranchWanted(req),
554
+ filePath,
555
+ message,
556
+ author: authorFor(viewer, req),
557
+ expectedHead: expected,
558
+ action: { kind: 'edit', content: Buffer.from(content, 'utf8'), toPath },
559
+ });
560
+ }
561
+ catch (e) {
562
+ await handleOpError(e, req, res, viewer, target, retryUrl);
563
+ return;
564
+ }
565
+ const onto = written.branch;
566
+ fire(loaded.repo, onto, expected, written.sha, viewer.auth.username);
567
+ // A commit on a new branch wants to be seen against the one it left, so
568
+ // that is where the editor lands.
569
+ if (onto !== branch) {
570
+ res.redirect(`${urlOf(loaded.repo)}/compare/${(0, views_1.encPath)(branch)}...${(0, views_1.encPath)(onto)}`);
571
+ return;
572
+ }
573
+ res.redirect(`${urlOf(loaded.repo)}/blob/${(0, views_1.encPath)(onto)}/${(0, views_1.encPath)(toPath ?? filePath)}`);
574
+ }));
575
+ // ---- uploading files ----
576
+ // The body is multipart, which express does not parse, so it arrives raw and
577
+ // multipart.ts takes it apart. The cap is enforced twice: here, where a
578
+ // larger body is refused before it is read, and again on the sum of the
579
+ // parts.
580
+ const uploadBody = express_1.default.raw({ type: 'multipart/form-data', limit: ops_1.MAX_UPLOAD_SIZE });
581
+ app.get('/:collection/:repo/upload/*', (0, web_1.ah)(async (req, res) => {
582
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
583
+ if (!viewer)
584
+ return;
585
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: true });
586
+ if (!target)
587
+ return;
588
+ const ctx = await (0, web_1.makeCtx)(root, req, target.loaded, target.branch, viewer);
589
+ res.type('html').send(forms.uploadPage(ctx, target.branch, target.filePath, target.tip, ops_1.MAX_UPLOAD_SIZE));
590
+ }));
591
+ app.post('/:collection/:repo/upload/*', uploadBody, (0, web_1.ah)(async (req, res) => {
592
+ const viewer = (0, session_1.getViewer)(req, root);
593
+ if (!viewer) {
594
+ (0, web_1.fail)(res, 403, 'You must be signed in to do that.', null, '/login');
595
+ return;
596
+ }
597
+ const boundary = (0, multipart_1.boundaryOf)(req.get('content-type'));
598
+ if (!boundary || !Buffer.isBuffer(req.body)) {
599
+ (0, web_1.fail)(res, 400, 'That upload did not arrive as a form; try again.', viewer);
600
+ return;
601
+ }
602
+ const parts = (0, multipart_1.parseMultipart)(req.body, boundary);
603
+ // The CSRF value rides in the same form, so it is compared from the
604
+ // parsed parts: express did not fill in req.body for a multipart post.
605
+ if (!(0, session_1.csrfMatches)(req, (0, multipart_1.partField)(parts, 'csrf'), viewer)) {
606
+ (0, web_1.fail)(res, 403, 'The form has expired; go back, reload the page, and try again.', viewer);
607
+ return;
608
+ }
609
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: true });
610
+ if (!target)
611
+ return;
612
+ const { loaded, branch, filePath: dir } = target;
613
+ const retryUrl = `${urlOf(loaded.repo)}/upload/${(0, views_1.encPath)(branch)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
614
+ const expectedField = (0, multipart_1.partField)(parts, 'expected');
615
+ const expected = expectedField === '' ? null : expectedField;
616
+ if (expected !== null && !(0, git_1.isValidSha)(expected)) {
617
+ (0, web_1.fail)(res, 400, 'The form is missing its base commit; reload and try again.', viewer, retryUrl);
618
+ return;
619
+ }
620
+ const uploads = (0, multipart_1.partFiles)(parts, 'files');
621
+ if (uploads.length === 0) {
622
+ (0, web_1.fail)(res, 400, 'Choose at least one file to upload.', viewer, retryUrl);
623
+ return;
624
+ }
625
+ const total = uploads.reduce((n, f) => n + f.data.length, 0);
626
+ if (total > ops_1.MAX_UPLOAD_SIZE) {
627
+ (0, web_1.fail)(res, 413, 'That is more than one upload may carry; push it with git instead.', viewer, retryUrl);
628
+ return;
629
+ }
630
+ // Browsers send the name the file had on disk, and some send a whole
631
+ // path; only the last segment is ours to use, and it still has to be a
632
+ // path this repository would accept.
633
+ const files = [];
634
+ for (const upload of uploads) {
635
+ const name = (upload.filename ?? '').split(/[\\/]/).pop() ?? '';
636
+ const full = dir === '' ? name : `${dir}/${name}`;
637
+ if (name === '' || name === '.' || name === '..' || !(0, git_1.isValidRepoPath)(full)) {
638
+ (0, web_1.fail)(res, 400, `${name || 'That file'} does not have a usable name.`, viewer, retryUrl);
639
+ return;
640
+ }
641
+ files.push({ path: full, content: upload.data });
642
+ }
643
+ const summary = (0, multipart_1.partField)(parts, 'message').trim();
644
+ const description = (0, multipart_1.partField)(parts, 'description').trim();
645
+ const fallback = files.length === 1 ? `Add ${files[0].path.split('/').pop()}` : `Add ${files.length} files`;
646
+ const message = `${summary || fallback}${description ? `\n\n${description}` : ''}`;
647
+ let onto = branch;
648
+ if ((0, multipart_1.partField)(parts, 'newBranchWanted') === '1') {
649
+ const wanted = (0, multipart_1.partField)(parts, 'newBranch').trim();
650
+ if (wanted === '' || expected === null) {
651
+ (0, web_1.fail)(res, 400, 'Name the new branch, or untick the box to commit to this one.', viewer, retryUrl);
652
+ return;
653
+ }
654
+ try {
655
+ await ops.createBranch(loaded.repo.dir, wanted, expected);
656
+ }
657
+ catch (e) {
658
+ const message2 = e instanceof ops_1.OpError ? e.message : 'Could not create that branch.';
659
+ (0, web_1.fail)(res, e instanceof ops_1.OpError ? (0, ops_1.opErrorStatus)(e.kind) : 400, message2, viewer, retryUrl);
660
+ return;
661
+ }
662
+ onto = wanted;
663
+ }
664
+ try {
665
+ const sha = await ops.commitFiles(loaded.repo.dir, {
666
+ branch: onto,
667
+ files,
668
+ message,
669
+ author: authorFor(viewer, req),
670
+ expectedHead: expected,
671
+ });
672
+ fire(loaded.repo, onto, expected, sha, viewer.auth.username);
673
+ }
674
+ catch (e) {
675
+ await handleOpError(e, req, res, viewer, target, retryUrl);
676
+ return;
677
+ }
678
+ if (onto !== branch) {
679
+ res.redirect(`${urlOf(loaded.repo)}/compare/${(0, views_1.encPath)(branch)}...${(0, views_1.encPath)(onto)}`);
680
+ return;
681
+ }
682
+ res.redirect(`${urlOf(loaded.repo)}/tree/${(0, views_1.encPath)(onto)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`);
683
+ }));
684
+ app.get('/:collection/:repo/new/*', (0, web_1.ah)(async (req, res) => {
685
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
686
+ if (!viewer)
687
+ return;
688
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: true });
689
+ if (!target)
690
+ return;
691
+ const ctx = await (0, web_1.makeCtx)(root, req, target.loaded, target.branch, viewer);
692
+ const preset = target.tip === null ? { filename: 'README.md', content: `# ${target.loaded.repo.name}\n` } : {};
693
+ res.type('html').send(forms.newFilePage(ctx, target.branch, target.filePath, target.tip, preset));
694
+ }));
695
+ app.post('/:collection/:repo/new/*', form, (0, web_1.ah)(async (req, res) => {
696
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
697
+ if (!viewer)
698
+ return;
699
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: true });
700
+ if (!target)
701
+ return;
702
+ const { loaded, branch, filePath: dir } = target;
703
+ const expectedField = (0, web_1.field)(req, 'expected');
704
+ const expected = expectedField === '' ? null : expectedField;
705
+ if (expected !== null && !(0, git_1.isValidSha)(expected)) {
706
+ (0, web_1.fail)(res, 400, 'The form is missing its base commit; reload and try again.', viewer);
707
+ return;
708
+ }
709
+ if (expected === null && target.tip !== null) {
710
+ // The form was rendered against an empty repository, but a branch has
711
+ // appeared since; treat it as the branch having moved.
712
+ await handleOpError(new ops_1.OpError('branch created meanwhile', 'conflict'), req, res, viewer, target, req.originalUrl);
713
+ return;
714
+ }
715
+ const filename = (0, web_1.field)(req, 'filename').trim().replace(/^\/+|\/+$/g, '');
716
+ const fullPath = dir === '' ? filename : `${dir}/${filename}`;
717
+ const retryUrl = req.originalUrl;
718
+ if (filename === '' || !(0, git_1.isValidRepoPath)(fullPath)) {
719
+ (0, web_1.fail)(res, 400, 'Invalid file name.', viewer, retryUrl);
720
+ return;
721
+ }
722
+ const content = normalizeContent((0, web_1.field)(req, 'content'));
723
+ const message = commitMessage(req, `Create ${filename.split('/').pop()}`);
724
+ let written;
725
+ try {
726
+ written = await ops.writeFile(loaded.repo.dir, {
727
+ branch,
728
+ newBranch: newBranchWanted(req),
729
+ filePath: fullPath,
730
+ message,
731
+ author: authorFor(viewer, req),
732
+ expectedHead: expected,
733
+ action: { kind: 'create', content: Buffer.from(content, 'utf8') },
734
+ });
735
+ }
736
+ catch (e) {
737
+ await handleOpError(e, req, res, viewer, target, retryUrl);
738
+ return;
739
+ }
740
+ const onto = written.branch;
741
+ fire(loaded.repo, onto, expected, written.sha, viewer.auth.username);
742
+ if (onto !== branch) {
743
+ res.redirect(`${urlOf(loaded.repo)}/compare/${(0, views_1.encPath)(branch)}...${(0, views_1.encPath)(onto)}`);
744
+ return;
745
+ }
746
+ res.redirect(`${urlOf(loaded.repo)}/blob/${(0, views_1.encPath)(onto)}/${(0, views_1.encPath)(fullPath)}`);
747
+ }));
748
+ app.get('/:collection/:repo/delete/*', (0, web_1.ah)(async (req, res) => {
749
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
750
+ if (!viewer)
751
+ return;
752
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: false });
753
+ if (!target)
754
+ return;
755
+ const { loaded, branch, filePath } = target;
756
+ const type = await loaded.repo.entryType(branch, filePath);
757
+ if (type !== 'blob') {
758
+ (0, web_1.send404)(res, `File ${filePath} not found at ${branch}`, viewer);
759
+ return;
760
+ }
761
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, branch, viewer);
762
+ res.type('html').send(forms.deleteFilePage(ctx, filePath, target.tip));
763
+ }));
764
+ app.post('/:collection/:repo/delete/*', form, (0, web_1.ah)(async (req, res) => {
765
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
766
+ if (!viewer)
767
+ return;
768
+ const target = await loadFileTarget(req, res, viewer, { allowEmptyRepo: false });
769
+ if (!target)
770
+ return;
771
+ const { loaded, branch, filePath } = target;
772
+ const expected = (0, web_1.field)(req, 'expected');
773
+ if (!(0, git_1.isValidSha)(expected)) {
774
+ (0, web_1.fail)(res, 400, 'The form is missing its base commit; reload and try again.', viewer);
775
+ return;
776
+ }
777
+ const message = commitMessage(req, `Delete ${filePath.split('/').pop()}`);
778
+ const retryUrl = `${urlOf(loaded.repo)}/delete/${(0, views_1.encPath)(branch)}/${(0, views_1.encPath)(filePath)}`;
779
+ try {
780
+ const sha = await ops.commitFileChange(loaded.repo.dir, {
781
+ branch,
782
+ filePath,
783
+ message,
784
+ author: authorFor(viewer, req),
785
+ expectedHead: expected,
786
+ action: { kind: 'delete' },
787
+ });
788
+ fire(loaded.repo, branch, expected, sha, viewer.auth.username);
789
+ }
790
+ catch (e) {
791
+ await handleOpError(e, req, res, viewer, target, retryUrl);
792
+ return;
793
+ }
794
+ const parent = filePath.split('/').slice(0, -1).join('/');
795
+ res.redirect(`${urlOf(loaded.repo)}/tree/${(0, views_1.encPath)(branch)}${parent ? `/${(0, views_1.encPath)(parent)}` : ''}`);
796
+ }));
797
+ // ---- branches and tags ----
798
+ async function loadForRefOp(req, res, viewer) {
799
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
800
+ if (!loaded)
801
+ return null;
802
+ if (!(0, perms_1.atLeast)(loaded.role, 'write')) {
803
+ (0, web_1.fail)(res, 403, `You do not have the write role on ${loaded.repo.collection}/${loaded.repo.name}.`, viewer, urlOf(loaded.repo));
804
+ return null;
805
+ }
806
+ return loaded;
807
+ }
808
+ app.post('/:collection/:repo/branches/create', form, (0, web_1.ah)(async (req, res) => {
809
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
810
+ if (!viewer)
811
+ return;
812
+ const loaded = await loadForRefOp(req, res, viewer);
813
+ if (!loaded)
814
+ return;
815
+ const backUrl = `${urlOf(loaded.repo)}/branches`;
816
+ const name = (0, web_1.field)(req, 'name').trim();
817
+ const from = (0, web_1.field)(req, 'from').trim() || loaded.defaultBranch || '';
818
+ try {
819
+ await ops.createBranch(loaded.repo.dir, name, from);
820
+ const created = loaded.branches.find((b) => b.name === from);
821
+ const tip = created?.sha ?? loaded.tags.find((t) => t.name === from)?.sha;
822
+ if (tip)
823
+ fire(loaded.repo, name, null, tip, viewer.auth.username);
824
+ }
825
+ catch (e) {
826
+ if (e instanceof ops_1.OpError) {
827
+ (0, web_1.fail)(res, (0, ops_1.opErrorStatus)(e.kind), e.message, viewer, backUrl);
828
+ return;
829
+ }
830
+ throw e;
831
+ }
832
+ res.redirect(backUrl);
833
+ }));
834
+ app.post('/:collection/:repo/branches/delete', form, (0, web_1.ah)(async (req, res) => {
835
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
836
+ if (!viewer)
837
+ return;
838
+ const loaded = await loadForRefOp(req, res, viewer);
839
+ if (!loaded)
840
+ return;
841
+ const backUrl = `${urlOf(loaded.repo)}/branches`;
842
+ const name = (0, web_1.field)(req, 'name');
843
+ if (!loaded.branches.some((b) => b.name === name)) {
844
+ (0, web_1.fail)(res, 404, `Branch ${name} not found.`, viewer, backUrl);
845
+ return;
846
+ }
847
+ if (name === loaded.defaultBranch) {
848
+ (0, web_1.fail)(res, 400, 'The default branch cannot be deleted. Change the default branch in Settings first.', viewer, backUrl);
849
+ return;
850
+ }
851
+ await ops.deleteBranch(loaded.repo.dir, name);
852
+ res.redirect(backUrl);
853
+ }));
854
+ app.post('/:collection/:repo/tags/create', form, (0, web_1.ah)(async (req, res) => {
855
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
856
+ if (!viewer)
857
+ return;
858
+ const loaded = await loadForRefOp(req, res, viewer);
859
+ if (!loaded)
860
+ return;
861
+ const backUrl = `${urlOf(loaded.repo)}/tags`;
862
+ const name = (0, web_1.field)(req, 'name').trim();
863
+ const at = (0, web_1.field)(req, 'at').trim() || loaded.defaultBranch || '';
864
+ try {
865
+ await ops.createTag(loaded.repo.dir, name, at);
866
+ }
867
+ catch (e) {
868
+ if (e instanceof ops_1.OpError) {
869
+ (0, web_1.fail)(res, (0, ops_1.opErrorStatus)(e.kind), e.message, viewer, backUrl);
870
+ return;
871
+ }
872
+ throw e;
873
+ }
874
+ res.redirect(backUrl);
875
+ }));
876
+ app.post('/:collection/:repo/tags/delete', form, (0, web_1.ah)(async (req, res) => {
877
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
878
+ if (!viewer)
879
+ return;
880
+ const loaded = await loadForRefOp(req, res, viewer);
881
+ if (!loaded)
882
+ return;
883
+ const backUrl = `${urlOf(loaded.repo)}/tags`;
884
+ const name = (0, web_1.field)(req, 'name');
885
+ if (!loaded.tags.some((t) => t.name === name)) {
886
+ (0, web_1.fail)(res, 404, `Tag ${name} not found.`, viewer, backUrl);
887
+ return;
888
+ }
889
+ await ops.deleteTag(loaded.repo.dir, name);
890
+ res.redirect(backUrl);
891
+ }));
892
+ // ---- repository settings ----
893
+ app.get('/:collection/:repo/settings', (0, web_1.ah)(async (req, res) => {
894
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
895
+ if (!viewer)
896
+ return;
897
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
898
+ if (!loaded)
899
+ return;
900
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
901
+ if (!ctx.canPush && !ctx.canAdmin) {
902
+ (0, web_1.fail)(res, 403, 'You do not have access to this repository’s settings.', viewer, urlOf(loaded.repo));
903
+ return;
904
+ }
905
+ const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
906
+ const access = (0, perms_1.repoAccess)(loaded.repo.dir);
907
+ res.type('html').send(forms.settingsPage(ctx, (0, scan_1.repoDescription)(loaded.repo.dir) ?? '', {
908
+ collaborators: Object.entries(access.collaborators).map(([username, role]) => ({ username, role })),
909
+ owners: (0, perms_1.collectionOwners)(root, loaded.repo.collection),
910
+ }, msg));
911
+ }));
912
+ app.post('/:collection/:repo/settings', form, (0, web_1.ah)(async (req, res) => {
913
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
914
+ if (!viewer)
915
+ return;
916
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
917
+ if (!loaded)
918
+ return;
919
+ const backUrl = `${urlOf(loaded.repo)}/settings`;
920
+ if (!(0, perms_1.atLeast)(loaded.role, 'write')) {
921
+ (0, web_1.fail)(res, 403, `You do not have the write role on ${loaded.repo.collection}/${loaded.repo.name}.`, viewer, backUrl);
922
+ return;
923
+ }
924
+ ops.setDescription(loaded.repo.dir, (0, web_1.field)(req, 'description'));
925
+ const defaultBranch = (0, web_1.field)(req, 'defaultBranch');
926
+ if (defaultBranch !== '' && defaultBranch !== loaded.defaultBranch) {
927
+ if (!loaded.branches.some((b) => b.name === defaultBranch)) {
928
+ (0, web_1.fail)(res, 400, `Branch ${defaultBranch} not found.`, viewer, backUrl);
929
+ return;
930
+ }
931
+ await ops.setDefaultBranch(loaded.repo.dir, defaultBranch);
932
+ }
933
+ res.redirect(`${backUrl}?msg=${encodeURIComponent('Settings saved.')}`);
934
+ }));
935
+ // ---- visibility and collaborators ----
936
+ // Both take the admin role: who may see or write a repository is decided by
937
+ // the people who administer it, the same rule the JSON API applies.
938
+ async function loadForRepoAdmin(req, res, viewer) {
939
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
940
+ if (!loaded)
941
+ return null;
942
+ if (!(0, perms_1.atLeast)(loaded.role, 'admin')) {
943
+ (0, web_1.fail)(res, 403, `This takes the admin role on ${loaded.repo.collection}/${loaded.repo.name}.`, viewer, `${urlOf(loaded.repo)}/settings`);
944
+ return null;
945
+ }
946
+ return loaded;
947
+ }
948
+ app.post('/:collection/:repo/settings/visibility', form, (0, web_1.ah)(async (req, res) => {
949
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
950
+ if (!viewer)
951
+ return;
952
+ const loaded = await loadForRepoAdmin(req, res, viewer);
953
+ if (!loaded)
954
+ return;
955
+ const backUrl = `${urlOf(loaded.repo)}/settings`;
956
+ const priv = (0, web_1.field)(req, 'private') === 'true';
957
+ (0, perms_1.setRepoPrivate)(loaded.repo.dir, priv);
958
+ const msg = priv
959
+ ? 'This repository is now private: visible to its collaborators, the collection’s owners, and site admins.'
960
+ : 'This repository is now public: anyone can read it.';
961
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(msg)}`);
962
+ }));
963
+ app.post('/:collection/:repo/settings/collaborators', form, (0, web_1.ah)(async (req, res) => {
964
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
965
+ if (!viewer)
966
+ return;
967
+ const loaded = await loadForRepoAdmin(req, res, viewer);
968
+ if (!loaded)
969
+ return;
970
+ const backUrl = `${urlOf(loaded.repo)}/settings`;
971
+ const username = (0, web_1.field)(req, 'username').trim();
972
+ const role = (0, web_1.field)(req, 'role');
973
+ if (role !== 'read' && role !== 'write' && role !== 'admin') {
974
+ (0, web_1.fail)(res, 400, 'The role must be read, write, or admin.', viewer, backUrl);
975
+ return;
976
+ }
977
+ // Only a user the vault knows: an entry for a name nobody holds grants
978
+ // nothing today and everything it says to whoever gets the name later.
979
+ const state = (0, vault_1.loadVault)(root);
980
+ if (state.status !== 'ok' || !state.vault.users[username]) {
981
+ (0, web_1.fail)(res, 404, `No user ${username || '(no name given)'} in this vault.`, viewer, backUrl);
982
+ return;
983
+ }
984
+ (0, perms_1.setCollaborator)(loaded.repo.dir, username, role);
985
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(`${username} now has the ${role} role here.`)}`);
986
+ }));
987
+ app.post('/:collection/:repo/settings/collaborators/remove', form, (0, web_1.ah)(async (req, res) => {
988
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
989
+ if (!viewer)
990
+ return;
991
+ const loaded = await loadForRepoAdmin(req, res, viewer);
992
+ if (!loaded)
993
+ return;
994
+ const backUrl = `${urlOf(loaded.repo)}/settings`;
995
+ const username = (0, web_1.field)(req, 'username').trim();
996
+ if ((0, perms_1.repoAccess)(loaded.repo.dir).collaborators[username] === undefined) {
997
+ (0, web_1.fail)(res, 404, `${username} is not a collaborator here.`, viewer, backUrl);
998
+ return;
999
+ }
1000
+ (0, perms_1.removeCollaborator)(loaded.repo.dir, username);
1001
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(`Removed ${username}.`)}`);
1002
+ }));
1003
+ // ---- forking ----
1004
+ app.get('/:collection/:repo/fork', (0, web_1.ah)(async (req, res) => {
1005
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
1006
+ if (!viewer)
1007
+ return;
1008
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
1009
+ if (!loaded)
1010
+ return;
1011
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
1012
+ // A vault usually has a collection named after each user, so that is the
1013
+ // suggestion; anything the actor may push to is accepted.
1014
+ res
1015
+ .type('html')
1016
+ .send(forms.forkPage(ctx, viewer, (0, scan_1.listCollections)(root).map((c) => c.name), {
1017
+ collection: viewer.auth.username,
1018
+ name: loaded.repo.name,
1019
+ }));
1020
+ }));
1021
+ app.post('/:collection/:repo/fork', form, (0, web_1.ah)(async (req, res) => {
1022
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
1023
+ if (!viewer)
1024
+ return;
1025
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
1026
+ if (!loaded)
1027
+ return;
1028
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
1029
+ const toCollection = (0, web_1.field)(req, 'collection').trim();
1030
+ const toName = (0, web_1.field)(req, 'name').trim() || loaded.repo.name;
1031
+ const names = (0, scan_1.listCollections)(root).map((c) => c.name);
1032
+ if (!(0, scan_1.isValidName)(toCollection) || !(0, scan_1.isValidName)(toName) || !(0, perms_1.canCreateRepo)(root, viewer.auth, toCollection, toName)) {
1033
+ res
1034
+ .status(403)
1035
+ .type('html')
1036
+ .send(forms.forkPage(ctx, viewer, names, { collection: toCollection, name: toName }, `You are not allowed to create repositories in ${toCollection || '(no collection)'}.`));
1037
+ return;
1038
+ }
1039
+ try {
1040
+ await ops.forkRepo(root, loaded.repo.collection, loaded.repo.name, toCollection, toName);
1041
+ }
1042
+ catch (e) {
1043
+ const message = e instanceof ops_1.OpError ? e.message : 'Could not fork the repository.';
1044
+ res
1045
+ .status(e instanceof ops_1.OpError ? (0, ops_1.opErrorStatus)(e.kind) : 400)
1046
+ .type('html')
1047
+ .send(forms.forkPage(ctx, viewer, names, { collection: toCollection, name: toName }, message));
1048
+ return;
1049
+ }
1050
+ res.redirect((0, views_1.repoUrl)({ collection: toCollection, repo: toName }));
1051
+ }));
1052
+ app.post('/:collection/:repo/settings/rename', form, (0, web_1.ah)(async (req, res) => {
1053
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
1054
+ if (!viewer)
1055
+ return;
1056
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
1057
+ if (!loaded)
1058
+ return;
1059
+ const from = `${loaded.repo.collection}/${loaded.repo.name}`;
1060
+ const backUrl = `${urlOf(loaded.repo)}/settings`;
1061
+ const toCollection = (0, web_1.field)(req, 'collection').trim() || loaded.repo.collection;
1062
+ const toName = (0, web_1.field)(req, 'name').trim();
1063
+ const blocker = (0, perms_1.repoRenameBlocker)(root, viewer.auth, loaded.repo, toCollection, toName);
1064
+ if (blocker) {
1065
+ (0, web_1.fail)(res, 403, `Renaming or moving this repository needs ${blocker}.`, viewer, backUrl);
1066
+ return;
1067
+ }
1068
+ try {
1069
+ await ops.renameRepo(root, loaded.repo.collection, loaded.repo.name, toCollection, toName, repoCtx);
1070
+ }
1071
+ catch (e) {
1072
+ const message = e instanceof ops_1.OpError ? e.message : 'Could not move the repository.';
1073
+ (0, web_1.fail)(res, e instanceof ops_1.OpError ? (0, ops_1.opErrorStatus)(e.kind) : 400, message, viewer, backUrl);
1074
+ return;
1075
+ }
1076
+ const to = (0, views_1.repoUrl)({ collection: toCollection, repo: toName });
1077
+ res.redirect(`${to}/settings?msg=${encodeURIComponent(`Moved from ${from}.`)}`);
1078
+ }));
1079
+ app.post('/:collection/:repo/settings/delete', form, (0, web_1.ah)(async (req, res) => {
1080
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
1081
+ if (!viewer)
1082
+ return;
1083
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
1084
+ if (!loaded)
1085
+ return;
1086
+ const backUrl = `${urlOf(loaded.repo)}/settings`;
1087
+ const target = `${loaded.repo.collection}/${loaded.repo.name}`;
1088
+ if (!(0, perms_1.atLeast)(loaded.role, 'admin')) {
1089
+ (0, web_1.fail)(res, 403, `Repository deletion requires the admin role on ${target}.`, viewer, backUrl);
1090
+ return;
1091
+ }
1092
+ if ((0, web_1.field)(req, 'confirm').trim() !== target) {
1093
+ (0, web_1.fail)(res, 400, `Type ${target} exactly to confirm deletion.`, viewer, backUrl);
1094
+ return;
1095
+ }
1096
+ await ops.deleteRepo(root, loaded.repo.collection, loaded.repo.name, repoCtx);
1097
+ res.redirect(`/${encodeURIComponent(loaded.repo.collection)}`);
1098
+ }));
1099
+ // ---- user administration ----
1100
+ // Users are the site admin's business, mirroring the JSON API: creating
1101
+ // them, the site-admin bit, and their tokens. What a user may reach is not
1102
+ // set here; it lives with the repositories (collaborators) and collections
1103
+ // (owners) that grant it.
1104
+ function requireAdminPage(req, res) {
1105
+ const viewer = (0, web_1.requireViewerPage)(root, req, res);
1106
+ if (!viewer)
1107
+ return null;
1108
+ if (!(0, session_1.viewerIsAdmin)(viewer)) {
1109
+ (0, web_1.fail)(res, 403, 'Site admin access required (sessions from restricted tokens carry no admin rights).', viewer, '/');
1110
+ return null;
1111
+ }
1112
+ return viewer;
1113
+ }
1114
+ function requireAdminPost(req, res) {
1115
+ const viewer = (0, web_1.requireViewerPost)(root, req, res);
1116
+ if (!viewer)
1117
+ return null;
1118
+ if (!(0, session_1.viewerIsAdmin)(viewer)) {
1119
+ (0, web_1.fail)(res, 403, 'Site admin access required (sessions from restricted tokens carry no admin rights).', viewer, '/');
1120
+ return null;
1121
+ }
1122
+ return viewer;
1123
+ }
1124
+ // Everything under /admin already takes a site admin; the helper survives
1125
+ // so a page for a weaker audience added later has the check to reach for.
1126
+ function canSetVaultWide(viewer) {
1127
+ return (0, perms_1.isSiteAdmin)(viewer.auth);
1128
+ }
1129
+ app.get('/admin', (req, res) => {
1130
+ const viewer = requireAdminPage(req, res);
1131
+ if (!viewer)
1132
+ return;
1133
+ res.type('html').send(forms.adminIndexPage(viewer, canSetVaultWide(viewer)));
1134
+ });
1135
+ app.get('/admin/egress', (req, res) => {
1136
+ const viewer = requireAdminPage(req, res);
1137
+ if (!viewer)
1138
+ return;
1139
+ if (!canSetVaultWide(viewer)) {
1140
+ const why = 'The egress budget is vault-wide, so it takes a site admin.';
1141
+ (0, web_1.fail)(res, 403, why, viewer, '/admin');
1142
+ return;
1143
+ }
1144
+ if (!egress) {
1145
+ (0, web_1.fail)(res, 500, 'This server is not counting outgoing bytes.', viewer, '/admin');
1146
+ return;
1147
+ }
1148
+ const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
1149
+ res.type('html').send(forms.egressPage(viewer, egress.snapshot(), { lfsBucket: lfs?.offloaded ?? false, msg }));
1150
+ });
1151
+ app.post('/admin/egress', form, (req, res) => {
1152
+ const viewer = requireAdminPost(req, res);
1153
+ if (!viewer)
1154
+ return;
1155
+ if (!canSetVaultWide(viewer)) {
1156
+ const why = 'The egress budget is vault-wide, so it takes a site admin.';
1157
+ (0, web_1.fail)(res, 403, why, viewer, '/admin');
1158
+ return;
1159
+ }
1160
+ const raw = (0, web_1.field)(req, 'egressGbPerDay').trim();
1161
+ const gb = Number(raw);
1162
+ if (raw === '' || !Number.isFinite(gb) || gb < 0) {
1163
+ (0, web_1.fail)(res, 400, 'Give a number of gigabytes per day, or 0 to send without a limit.', viewer, '/admin/egress');
1164
+ return;
1165
+ }
1166
+ // The whole block is written back, since saveConfig replaces a top-level key
1167
+ // rather than merging into it. Every other field keeps the value it has, so a
1168
+ // vault that has tuned its concurrencies by hand does not lose them here.
1169
+ (0, config_1.saveConfig)(root, { limits: { ...(0, config_1.loadConfig)(root).limits, egressGbPerDay: gb } });
1170
+ const msg = gb > 0 ? `Daily egress limit set to ${gb} GB.` : 'Daily egress limit removed.';
1171
+ res.redirect(`/admin/egress?msg=${encodeURIComponent(msg)}`);
1172
+ });
1173
+ app.get('/admin/appearance', (req, res) => {
1174
+ const viewer = requireAdminPage(req, res);
1175
+ if (!viewer)
1176
+ return;
1177
+ if (!canSetVaultWide(viewer)) {
1178
+ (0, web_1.fail)(res, 403, 'Changing the theme takes a site admin.', viewer, '/admin');
1179
+ return;
1180
+ }
1181
+ const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
1182
+ res.type('html').send(forms.appearancePage(viewer, themes_1.THEMES, (0, config_1.loadConfig)(root).theme, msg));
1183
+ });
1184
+ app.post('/admin/appearance', form, (req, res) => {
1185
+ const viewer = requireAdminPost(req, res);
1186
+ if (!viewer)
1187
+ return;
1188
+ if (!canSetVaultWide(viewer)) {
1189
+ (0, web_1.fail)(res, 403, 'Changing the theme takes a site admin.', viewer, '/admin');
1190
+ return;
1191
+ }
1192
+ const name = (0, web_1.field)(req, 'theme');
1193
+ if (!(0, themes_1.findTheme)(name)) {
1194
+ (0, web_1.fail)(res, 400, `Unknown theme: ${name || '(none selected)'}.`, viewer, '/admin/appearance');
1195
+ return;
1196
+ }
1197
+ (0, config_1.saveConfig)(root, { theme: name });
1198
+ (0, themes_1.setActiveTheme)(name);
1199
+ res.redirect(`/admin/appearance?msg=${encodeURIComponent(`Theme set to ${name}.`)}`);
1200
+ });
1201
+ app.get('/admin/users', (req, res) => {
1202
+ const viewer = requireAdminPage(req, res);
1203
+ if (!viewer)
1204
+ return;
1205
+ const state = (0, vault_1.loadVault)(root);
1206
+ if (state.status !== 'ok') {
1207
+ (0, web_1.fail)(res, 500, 'The vault is not available.', viewer);
1208
+ return;
1209
+ }
1210
+ const users = Object.entries(state.vault.users).map(([name, user]) => ({ name, user }));
1211
+ const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
1212
+ res.type('html').send(forms.adminUsersPage(viewer, users, msg));
1213
+ });
1214
+ app.post('/admin/users', form, (req, res) => {
1215
+ const viewer = requireAdminPost(req, res);
1216
+ if (!viewer)
1217
+ return;
1218
+ const username = (0, web_1.field)(req, 'username').trim();
1219
+ const backUrl = '/admin/users';
1220
+ if (!(0, scan_1.isValidUserName)(username)) {
1221
+ (0, web_1.fail)(res, 400, 'A valid username is required (letters, digits, dot, underscore, dash, not starting with a dot).', viewer, backUrl);
1222
+ return;
1223
+ }
1224
+ const state = (0, vault_1.loadVault)(root);
1225
+ if (state.status !== 'ok') {
1226
+ (0, web_1.fail)(res, 500, 'The vault is not available.', viewer, backUrl);
1227
+ return;
1228
+ }
1229
+ if (state.vault.users[username]) {
1230
+ (0, web_1.fail)(res, 409, `User ${username} already exists; use Grant or Mint token on the users page instead.`, viewer, backUrl);
1231
+ return;
1232
+ }
1233
+ const result = (0, vault_1.addUserToken)(root, username, { siteAdmin: (0, web_1.field)(req, 'siteAdmin') === 'true' });
1234
+ res.type('html').send(forms.tokenPage(viewer, username, result.token, true));
1235
+ });
1236
+ /**
1237
+ * One user, with their tokens laid out and revocable. requireAdminPage has
1238
+ * already established the actor is a site admin, which is the whole
1239
+ * authorization here, as it is on the JSON API.
1240
+ */
1241
+ function loadUserForAdmin(req, res, viewer, backUrl) {
1242
+ const name = req.params.name;
1243
+ if (!(0, scan_1.isValidName)(name)) {
1244
+ (0, web_1.fail)(res, 400, 'Invalid username.', viewer, backUrl);
1245
+ return null;
1246
+ }
1247
+ const state = (0, vault_1.loadVault)(root);
1248
+ if (state.status !== 'ok') {
1249
+ (0, web_1.fail)(res, 500, 'The vault is not available.', viewer, backUrl);
1250
+ return null;
1251
+ }
1252
+ const user = state.vault.users[name];
1253
+ if (!user) {
1254
+ (0, web_1.fail)(res, 404, `No user ${name}.`, viewer, backUrl);
1255
+ return null;
1256
+ }
1257
+ return { name, user };
1258
+ }
1259
+ app.get('/admin/users/:name', (req, res) => {
1260
+ const viewer = requireAdminPage(req, res);
1261
+ if (!viewer)
1262
+ return;
1263
+ const found = loadUserForAdmin(req, res, viewer, '/admin/users');
1264
+ if (!found)
1265
+ return;
1266
+ const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
1267
+ res.type('html').send(forms.adminUserPage(viewer, found.name, found.user, msg));
1268
+ });
1269
+ app.post('/admin/users/:name/tokens/:id/revoke', form, (req, res) => {
1270
+ const viewer = requireAdminPost(req, res);
1271
+ if (!viewer)
1272
+ return;
1273
+ const backUrl = `/admin/users/${encodeURIComponent(req.params.name)}`;
1274
+ const found = loadUserForAdmin(req, res, viewer, backUrl);
1275
+ if (!found)
1276
+ return;
1277
+ const wasThisSession = found.name === viewer.auth.username && (0, vault_1.tokenId)(viewer.auth.token) === req.params.id;
1278
+ let result;
1279
+ try {
1280
+ result = (0, vault_1.revokeToken)(root, found.name, req.params.id);
1281
+ }
1282
+ catch (e) {
1283
+ (0, web_1.fail)(res, 500, e instanceof Error ? e.message : String(e), viewer, backUrl);
1284
+ return;
1285
+ }
1286
+ if (!result.revoked) {
1287
+ (0, web_1.fail)(res, 404, `No token ${req.params.id} for ${found.name}.`, viewer, backUrl);
1288
+ return;
1289
+ }
1290
+ // Revoking the session's own token is allowed, and the session it ends is
1291
+ // this one: the redirect lands on the sign-in page rather than pretending
1292
+ // otherwise.
1293
+ if (wasThisSession) {
1294
+ res.redirect('/login');
1295
+ return;
1296
+ }
1297
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(`Revoked token ${req.params.id}.`)}`);
1298
+ });
1299
+ app.post('/admin/users/:name/emails', form, (req, res) => {
1300
+ const viewer = requireAdminPost(req, res);
1301
+ if (!viewer)
1302
+ return;
1303
+ const backUrl = `/admin/users/${encodeURIComponent(req.params.name)}`;
1304
+ const found = loadUserForAdmin(req, res, viewer, backUrl);
1305
+ if (!found)
1306
+ return;
1307
+ const emails = (0, web_1.field)(req, 'emails')
1308
+ .split(/[\s,]+/)
1309
+ .filter((s) => s.length > 0);
1310
+ // The shape check is deliberately loose -- an @ with something either
1311
+ // side -- since git itself enforces nothing about author emails and the
1312
+ // point is to match whatever this person's commits actually carry.
1313
+ const bad = emails.find((e) => e.length >= 200 || !/^[^@\s]+@[^@\s]+$/.test(e));
1314
+ if (bad !== undefined) {
1315
+ (0, web_1.fail)(res, 400, `That does not look like an email: ${bad}`, viewer, backUrl);
1316
+ return;
1317
+ }
1318
+ (0, vault_1.setUserEmails)(root, found.name, emails);
1319
+ const msg = emails.length ? `Emails saved for ${found.name}.` : `Emails cleared for ${found.name}.`;
1320
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(msg)}`);
1321
+ });
1322
+ app.post('/admin/users/:name/delete', form, (req, res) => {
1323
+ const viewer = requireAdminPost(req, res);
1324
+ if (!viewer)
1325
+ return;
1326
+ const backUrl = `/admin/users/${encodeURIComponent(req.params.name)}`;
1327
+ const found = loadUserForAdmin(req, res, viewer, backUrl);
1328
+ if (!found)
1329
+ return;
1330
+ // Deleting yourself would leave a vault its owner cannot administer except
1331
+ // by hand, and unlike revoking one token it cannot be undone by minting
1332
+ // another.
1333
+ if (found.name === viewer.auth.username) {
1334
+ (0, web_1.fail)(res, 409, 'A user cannot delete themselves; another admin can, or edit vault.json by hand.', viewer, backUrl);
1335
+ return;
1336
+ }
1337
+ if ((0, web_1.field)(req, 'confirm').trim() !== found.name) {
1338
+ (0, web_1.fail)(res, 400, `Type ${found.name} exactly to confirm deletion.`, viewer, backUrl);
1339
+ return;
1340
+ }
1341
+ // Their grants go with them: a collaborator entry or an owners listing
1342
+ // left behind would belong to whoever is given this name next.
1343
+ if ((0, vault_1.removeUser)(root, found.name))
1344
+ (0, perms_1.removeUserGrants)(root, found.name);
1345
+ res.redirect(`/admin/users?msg=${encodeURIComponent(`Deleted ${found.name} and revoked their tokens.`)}`);
1346
+ });
1347
+ app.post('/admin/users/:name/grant', form, (req, res) => {
1348
+ const viewer = requireAdminPost(req, res);
1349
+ if (!viewer)
1350
+ return;
1351
+ const username = req.params.name;
1352
+ const backUrl = safeNext((0, web_1.field)(req, 'next')) === '/' ? '/admin/users' : safeNext((0, web_1.field)(req, 'next'));
1353
+ if (!(0, scan_1.isValidName)(username)) {
1354
+ (0, web_1.fail)(res, 400, 'Invalid username.', viewer, backUrl);
1355
+ return;
1356
+ }
1357
+ const value = (0, web_1.field)(req, 'siteAdmin');
1358
+ if (value !== 'true' && value !== 'false') {
1359
+ (0, web_1.fail)(res, 400, 'Say whether to grant or withdraw site admin.', viewer, backUrl);
1360
+ return;
1361
+ }
1362
+ try {
1363
+ (0, vault_1.setSiteAdmin)(root, username, value === 'true');
1364
+ }
1365
+ catch (e) {
1366
+ (0, web_1.fail)(res, 404, e instanceof Error ? e.message : String(e), viewer, backUrl);
1367
+ return;
1368
+ }
1369
+ const msg = value === 'true' ? `${username} is now a site admin.` : `${username} is no longer a site admin.`;
1370
+ res.redirect(`${backUrl}?msg=${encodeURIComponent(msg)}`);
1371
+ });
1372
+ app.post('/admin/users/:name/token', form, (req, res) => {
1373
+ const viewer = requireAdminPost(req, res);
1374
+ if (!viewer)
1375
+ return;
1376
+ const username = req.params.name;
1377
+ const backUrl = '/admin/users';
1378
+ if (!(0, scan_1.isValidName)(username)) {
1379
+ (0, web_1.fail)(res, 400, 'Invalid username.', viewer, backUrl);
1380
+ return;
1381
+ }
1382
+ const state = (0, vault_1.loadVault)(root);
1383
+ if (state.status !== 'ok') {
1384
+ (0, web_1.fail)(res, 500, 'The vault is not available.', viewer, backUrl);
1385
+ return;
1386
+ }
1387
+ const existing = state.vault.users[username];
1388
+ if (!existing) {
1389
+ (0, web_1.fail)(res, 404, `User ${username} does not exist.`, viewer, backUrl);
1390
+ return;
1391
+ }
1392
+ const tokenScope = globsField(req, 'tokenScope');
1393
+ if (tokenScope === null) {
1394
+ (0, web_1.fail)(res, 400, 'Token scope globs are too long.', viewer, backUrl);
1395
+ return;
1396
+ }
1397
+ const result = (0, vault_1.addUserToken)(root, username, {
1398
+ tokenScope: tokenScope.length ? tokenScope : undefined,
1399
+ });
1400
+ res.type('html').send(forms.tokenPage(viewer, username, result.token, false));
1401
+ });
1402
+ }