@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/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
|
+
}
|