@magland/mochi 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +138 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +192 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/webops.js
CHANGED
|
@@ -48,13 +48,24 @@ const themes_1 = require("./themes");
|
|
|
48
48
|
const forms = __importStar(require("./forms"));
|
|
49
49
|
const ops = __importStar(require("./ops"));
|
|
50
50
|
const ops_1 = require("./ops");
|
|
51
|
+
const agefile_1 = require("./agefile");
|
|
52
|
+
const topics_1 = require("./topics");
|
|
51
53
|
const scan_1 = require("./scan");
|
|
54
|
+
const domains_1 = require("./domains");
|
|
55
|
+
const siteshost_1 = require("./siteshost");
|
|
56
|
+
const sitesettings_1 = require("./sitesettings");
|
|
57
|
+
const sitepublish_1 = require("./sitepublish");
|
|
52
58
|
const session_1 = require("./session");
|
|
53
59
|
const perms_1 = require("./perms");
|
|
54
60
|
const vault_1 = require("./vault");
|
|
61
|
+
const githubauth_1 = require("./githubauth");
|
|
62
|
+
const webauthn_1 = require("./webauthn");
|
|
63
|
+
const onetime_1 = require("./onetime");
|
|
64
|
+
const logincodes_1 = require("./logincodes");
|
|
55
65
|
const views_1 = require("./views");
|
|
56
66
|
const web_1 = require("./web");
|
|
57
67
|
const render_1 = require("./render");
|
|
68
|
+
const markdown_1 = require("./markdown");
|
|
58
69
|
const multipart_1 = require("./multipart");
|
|
59
70
|
function urlOf(repo) {
|
|
60
71
|
return (0, views_1.repoUrl)({ collection: repo.collection, repo: repo.name });
|
|
@@ -141,13 +152,32 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
141
152
|
res.redirect(next);
|
|
142
153
|
return;
|
|
143
154
|
}
|
|
144
|
-
res.type('html').send(forms.loginPage(next));
|
|
155
|
+
res.type('html').send(forms.loginPage(next, undefined, (0, githubauth_1.githubConfigured)(root)));
|
|
145
156
|
});
|
|
157
|
+
// A sign-in form posted from another site is refused. None of these
|
|
158
|
+
// handlers have a session to check a CSRF token against, since their whole
|
|
159
|
+
// purpose is to start one, so the Origin header is the check: a browser
|
|
160
|
+
// sends it on every cross-site POST, and a page elsewhere that auto-submits
|
|
161
|
+
// an attacker's own username and token would otherwise sign the visitor in
|
|
162
|
+
// as the attacker without their noticing. A request with no Origin is
|
|
163
|
+
// allowed, as it is for the CSRF check: plenty of clients send none.
|
|
164
|
+
function refuseCrossSite(req, res) {
|
|
165
|
+
if ((0, session_1.originOk)(req))
|
|
166
|
+
return false;
|
|
167
|
+
(0, web_1.fail)(res, 403, 'This sign-in was posted from another site and was not accepted.', null, '/login');
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
146
170
|
app.post('/login', form, (req, res) => {
|
|
171
|
+
if (refuseCrossSite(req, res))
|
|
172
|
+
return;
|
|
147
173
|
const next = safeNext((0, web_1.field)(req, 'next'));
|
|
174
|
+
const github = (0, githubauth_1.githubConfigured)(root);
|
|
148
175
|
const state = (0, vault_1.loadVault)(root);
|
|
149
176
|
if (state.status !== 'ok') {
|
|
150
|
-
res
|
|
177
|
+
res
|
|
178
|
+
.status(500)
|
|
179
|
+
.type('html')
|
|
180
|
+
.send(forms.loginPage(next, 'The vault is not available; try again later.', github));
|
|
151
181
|
return;
|
|
152
182
|
}
|
|
153
183
|
const username = (0, web_1.field)(req, 'username');
|
|
@@ -161,7 +191,7 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
161
191
|
res.status(429).setHeader('Retry-After', String(allowed.retryAfter));
|
|
162
192
|
res
|
|
163
193
|
.type('html')
|
|
164
|
-
.send(forms.loginPage(next, `Too many failed sign-in attempts from this address. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}
|
|
194
|
+
.send(forms.loginPage(next, `Too many failed sign-in attempts from this address. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`, github));
|
|
165
195
|
return;
|
|
166
196
|
}
|
|
167
197
|
const auth = (0, vault_1.authenticate)(state.vault, username, (0, web_1.field)(req, 'token'));
|
|
@@ -169,18 +199,534 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
169
199
|
authLimiter.fail(req, username);
|
|
170
200
|
// One generic message: no username/token distinction. The refusal above
|
|
171
201
|
// says nothing about whether the username exists either.
|
|
172
|
-
res.status(401).type('html').send(forms.loginPage(next, 'Invalid username or token.'));
|
|
202
|
+
res.status(401).type('html').send(forms.loginPage(next, 'Invalid username or token.', github));
|
|
173
203
|
return;
|
|
174
204
|
}
|
|
175
205
|
(0, session_1.setSessionCookie)(req, res, root, auth);
|
|
176
206
|
res.redirect(next);
|
|
177
207
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
208
|
+
// Cleared without a CSRF check: signing someone out is not an action worth
|
|
209
|
+
// forging, and requiring a live token would leave a stale form redirecting
|
|
210
|
+
// to / still signed in with no sign anything went wrong.
|
|
211
|
+
app.post('/logout', form, (_req, res) => {
|
|
212
|
+
(0, session_1.clearSessionCookie)(res);
|
|
182
213
|
res.redirect('/');
|
|
183
214
|
});
|
|
215
|
+
// ---- passkeys, the account page, and sign-in codes ----
|
|
216
|
+
//
|
|
217
|
+
// Three more ways into a session, all ending at the same setSessionCookie
|
|
218
|
+
// the token form uses: a passkey (WebAuthn), a short code shown by a
|
|
219
|
+
// signed-in browser, and a one-time link minted by `mochi web` against the
|
|
220
|
+
// API. The WebAuthn endpoints speak JSON because the ceremonies run through
|
|
221
|
+
// fetch; everything else is ordinary forms.
|
|
222
|
+
const jsonForm = express_1.default.json({ limit: '64kb' });
|
|
223
|
+
// Pending WebAuthn challenges. The store's opaque id is the challenge
|
|
224
|
+
// itself: finishing a ceremony hands it back inside clientDataJSON, so
|
|
225
|
+
// nothing else has to travel, and taking it makes each challenge one use.
|
|
226
|
+
const registrationChallenges = (0, onetime_1.createOneTimeStore)();
|
|
227
|
+
const loginChallenges = (0, onetime_1.createOneTimeStore)();
|
|
228
|
+
const CHALLENGE_TTL_MS = 2 * 60 * 1000;
|
|
229
|
+
const WEBAUTHN_TIMEOUT_MS = 120000;
|
|
230
|
+
// The RP ID is the hostname the vault is being served under, which is what
|
|
231
|
+
// scopes a passkey to this vault: a key made for one hostname does not
|
|
232
|
+
// answer for another, and moving a vault to a new domain means registering
|
|
233
|
+
// new passkeys (tokens keep working, so nobody is locked out by the move).
|
|
234
|
+
const rpIdOf = (req) => req.hostname;
|
|
235
|
+
const originOf = (req) => `${req.protocol}://${req.get('host')}`;
|
|
236
|
+
// A restricted token may not add passkeys or hand off sessions broader than
|
|
237
|
+
// itself: a passkey signs in with the user's full standing, so minting one
|
|
238
|
+
// from a session that does not have it would widen the token it came from.
|
|
239
|
+
const restricted = (viewer) => viewer.auth.token.scope !== undefined;
|
|
240
|
+
function jsonViewer(req, res) {
|
|
241
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
242
|
+
if (!viewer) {
|
|
243
|
+
res.status(403).json({ error: 'You are signed out. Reload the page and sign in.' });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (!(0, session_1.csrfMatches)(req, (0, web_1.field)(req, 'csrf'), viewer)) {
|
|
247
|
+
res.status(403).json({ error: 'The page has expired; reload it and try again.' });
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return viewer;
|
|
251
|
+
}
|
|
252
|
+
app.get('/account', (req, res) => {
|
|
253
|
+
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
254
|
+
if (!viewer)
|
|
255
|
+
return;
|
|
256
|
+
const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
|
|
257
|
+
res.type('html').send(forms.accountPage(viewer, {
|
|
258
|
+
restricted: restricted(viewer),
|
|
259
|
+
msg,
|
|
260
|
+
github: { enabled: (0, githubauth_1.githubConfigured)(root), account: viewer.auth.user.github ?? null },
|
|
261
|
+
}));
|
|
262
|
+
});
|
|
263
|
+
app.post('/account/passkeys/challenge', jsonForm, (req, res) => {
|
|
264
|
+
const viewer = jsonViewer(req, res);
|
|
265
|
+
if (!viewer)
|
|
266
|
+
return;
|
|
267
|
+
if (restricted(viewer)) {
|
|
268
|
+
res.status(403).json({ error: 'A session from a restricted token may not add passkeys.' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const username = viewer.auth.username;
|
|
272
|
+
res.json({
|
|
273
|
+
challenge: registrationChallenges.put({ username }, CHALLENGE_TTL_MS),
|
|
274
|
+
rp: { id: rpIdOf(req), name: `Mochi Forge (${rpIdOf(req)})` },
|
|
275
|
+
// The user handle is what a later usernameless sign-in identifies the
|
|
276
|
+
// account by; usernames are short and stable, so they are it, verbatim.
|
|
277
|
+
user: { id: Buffer.from(username, 'utf8').toString('base64url'), name: username, displayName: username },
|
|
278
|
+
pubKeyCredParams: webauthn_1.SUPPORTED_ALGS.map((alg) => ({ type: 'public-key', alg })),
|
|
279
|
+
// Resident keys, so signing in needs no username typed first; every
|
|
280
|
+
// platform authenticator that offers passkeys keeps them.
|
|
281
|
+
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
|
282
|
+
timeout: WEBAUTHN_TIMEOUT_MS,
|
|
283
|
+
attestation: 'none',
|
|
284
|
+
excludeCredentials: (viewer.auth.user.passkeys ?? []).map((p) => ({ type: 'public-key', id: p.id })),
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
app.post('/account/passkeys', jsonForm, (req, res) => {
|
|
288
|
+
const viewer = jsonViewer(req, res);
|
|
289
|
+
if (!viewer)
|
|
290
|
+
return;
|
|
291
|
+
if (restricted(viewer)) {
|
|
292
|
+
res.status(403).json({ error: 'A session from a restricted token may not add passkeys.' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
let clientDataJSON;
|
|
296
|
+
let attestationObject;
|
|
297
|
+
try {
|
|
298
|
+
clientDataJSON = (0, webauthn_1.fromB64url)((0, web_1.field)(req, 'clientDataJSON'));
|
|
299
|
+
attestationObject = (0, webauthn_1.fromB64url)((0, web_1.field)(req, 'attestationObject'));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
res.status(400).json({ error: 'The registration payload is not base64url.' });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const challenge = (0, webauthn_1.claimedChallenge)(clientDataJSON);
|
|
306
|
+
const pending = challenge ? registrationChallenges.take(challenge) : null;
|
|
307
|
+
if (!pending || pending.username !== viewer.auth.username) {
|
|
308
|
+
res.status(400).json({ error: 'The challenge is missing or expired; try again.' });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let reg;
|
|
312
|
+
try {
|
|
313
|
+
reg = (0, webauthn_1.verifyRegistration)({
|
|
314
|
+
attestationObject,
|
|
315
|
+
clientDataJSON,
|
|
316
|
+
challenge: challenge,
|
|
317
|
+
origin: originOf(req),
|
|
318
|
+
rpId: rpIdOf(req),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
res.status(400).json({ error: e instanceof webauthn_1.WebAuthnError ? e.message : 'The registration could not be verified.' });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const name = (0, web_1.field)(req, 'name').trim().slice(0, 60);
|
|
326
|
+
try {
|
|
327
|
+
(0, vault_1.addPasskey)(root, viewer.auth.username, {
|
|
328
|
+
id: reg.id,
|
|
329
|
+
publicKey: reg.publicKey,
|
|
330
|
+
alg: reg.alg,
|
|
331
|
+
counter: reg.counter,
|
|
332
|
+
...(name ? { name } : {}),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
res.status(409).json({ error: e instanceof Error ? e.message : 'The passkey could not be stored.' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
res.json({ next: `/account?msg=${encodeURIComponent(`Passkey ${name ? `${name} ` : ''}added.`)}` });
|
|
340
|
+
});
|
|
341
|
+
app.post('/account/passkeys/:id/delete', form, (req, res) => {
|
|
342
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
343
|
+
if (!viewer)
|
|
344
|
+
return;
|
|
345
|
+
const wasThisSession = viewer.auth.token.hash === (0, vault_1.passkeyBinding)(req.params.id);
|
|
346
|
+
if (!(0, vault_1.removePasskey)(root, viewer.auth.username, req.params.id)) {
|
|
347
|
+
(0, web_1.fail)(res, 404, 'No such passkey on your account.', viewer, '/account');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Removing the passkey this session signed in with ends this session; the
|
|
351
|
+
// redirect says so instead of pretending otherwise.
|
|
352
|
+
if (wasThisSession) {
|
|
353
|
+
res.redirect('/login');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
res.redirect(`/account?msg=${encodeURIComponent('Passkey removed.')}`);
|
|
357
|
+
});
|
|
358
|
+
app.post('/account/link', form, (req, res) => {
|
|
359
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
360
|
+
if (!viewer)
|
|
361
|
+
return;
|
|
362
|
+
// The new session is bound to whatever this one is -- the same token
|
|
363
|
+
// hash (scope and all), or the same passkey -- so nothing new is minted,
|
|
364
|
+
// nothing widens, and one revocation ends both.
|
|
365
|
+
const code = (0, logincodes_1.mintHandoff)(root, viewer.auth.username, viewer.auth.token.hash);
|
|
366
|
+
res.type('html').send(forms.accountLinkPage(viewer, code, Math.round(logincodes_1.HANDOFF_TTL_MS / 60000)));
|
|
367
|
+
});
|
|
368
|
+
app.get('/login/link', (req, res) => {
|
|
369
|
+
const next = safeNext(String(req.query.next ?? '/'));
|
|
370
|
+
if ((0, session_1.getViewer)(req, root)) {
|
|
371
|
+
res.redirect(next);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
res.type('html').send(forms.loginLinkPage(next));
|
|
375
|
+
});
|
|
376
|
+
// Redeeming any kind of sign-in code shares the token form's rate limiter:
|
|
377
|
+
// a code is a credential being presented, and guessing them is the same
|
|
378
|
+
// attack mistyping tokens is throttled as.
|
|
379
|
+
function redeemPending(req, res, pending, renderRefusal) {
|
|
380
|
+
if (!pending) {
|
|
381
|
+
authLimiter.fail(req, null);
|
|
382
|
+
renderRefusal('That code is not valid. Codes work once and expire after a few minutes.', 401);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const state = (0, vault_1.loadVault)(root);
|
|
386
|
+
const auth = state.status === 'ok' ? (0, vault_1.authForBinding)(state.vault, pending.username, pending.binding) : null;
|
|
387
|
+
if (!auth) {
|
|
388
|
+
renderRefusal('The credential behind this code has been revoked; sign in another way.', 403);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
(0, session_1.setSessionCookie)(req, res, root, auth);
|
|
392
|
+
res.redirect(safeNext(pending.next ?? (0, web_1.field)(req, 'next')));
|
|
393
|
+
}
|
|
394
|
+
app.post('/login/link', form, (req, res) => {
|
|
395
|
+
if (refuseCrossSite(req, res))
|
|
396
|
+
return;
|
|
397
|
+
const next = safeNext((0, web_1.field)(req, 'next'));
|
|
398
|
+
const allowed = authLimiter.allow(req, null);
|
|
399
|
+
if (!allowed.ok) {
|
|
400
|
+
res.status(429).setHeader('Retry-After', String(allowed.retryAfter));
|
|
401
|
+
res.type('html').send(forms.loginLinkPage(next, 'Too many attempts from this address. Try again later.'));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
redeemPending(req, res, (0, logincodes_1.takeHandoff)(root, (0, web_1.field)(req, 'code')), (error, status) => {
|
|
405
|
+
res.status(status).type('html').send(forms.loginLinkPage(next, error));
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
// The landing page of a `mochi web` link: it looks the code up without
|
|
409
|
+
// consuming it and asks for a click, so a GET never changes who the browser
|
|
410
|
+
// is and a prefetch cannot burn the code.
|
|
411
|
+
app.get('/login/code/:code', (req, res) => {
|
|
412
|
+
const pending = (0, logincodes_1.peekLoginLink)(root, req.params.code);
|
|
413
|
+
if (!pending) {
|
|
414
|
+
(0, web_1.fail)(res, 404, 'This sign-in link has expired or was already used. Run mochi web again for a fresh one.', null, '/login');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
res.type('html').send(forms.loginLinkConfirmPage(pending.username, req.params.code, safeNext(pending.next ?? '/')));
|
|
418
|
+
});
|
|
419
|
+
app.post('/login/code', form, (req, res) => {
|
|
420
|
+
if (refuseCrossSite(req, res))
|
|
421
|
+
return;
|
|
422
|
+
const allowed = authLimiter.allow(req, null);
|
|
423
|
+
if (!allowed.ok) {
|
|
424
|
+
res.status(429).setHeader('Retry-After', String(allowed.retryAfter));
|
|
425
|
+
(0, web_1.fail)(res, 429, 'Too many attempts from this address. Try again later.', null, '/login');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
redeemPending(req, res, (0, logincodes_1.takeLoginLink)(root, (0, web_1.field)(req, 'code')), (error, status) => {
|
|
429
|
+
(0, web_1.fail)(res, status, error, null, '/login');
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
app.post('/login/passkey/challenge', jsonForm, (req, res) => {
|
|
433
|
+
res.json({
|
|
434
|
+
challenge: loginChallenges.put(true, CHALLENGE_TTL_MS),
|
|
435
|
+
rpId: rpIdOf(req),
|
|
436
|
+
timeout: WEBAUTHN_TIMEOUT_MS,
|
|
437
|
+
userVerification: 'preferred',
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
app.post('/login/passkey', jsonForm, (req, res) => {
|
|
441
|
+
if (!(0, session_1.originOk)(req)) {
|
|
442
|
+
res.status(403).json({ error: 'This sign-in was posted from another site and was not accepted.' });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const allowed = authLimiter.allow(req, null);
|
|
446
|
+
if (!allowed.ok) {
|
|
447
|
+
res.setHeader('Retry-After', String(allowed.retryAfter));
|
|
448
|
+
res.status(429).json({ error: 'Too many failed sign-in attempts from this address; try again later.' });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
let clientDataJSON;
|
|
452
|
+
let authenticatorData;
|
|
453
|
+
let signature;
|
|
454
|
+
try {
|
|
455
|
+
clientDataJSON = (0, webauthn_1.fromB64url)((0, web_1.field)(req, 'clientDataJSON'));
|
|
456
|
+
authenticatorData = (0, webauthn_1.fromB64url)((0, web_1.field)(req, 'authenticatorData'));
|
|
457
|
+
signature = (0, webauthn_1.fromB64url)((0, web_1.field)(req, 'signature'));
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
res.status(400).json({ error: 'The sign-in payload is not base64url.' });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const challenge = (0, webauthn_1.claimedChallenge)(clientDataJSON);
|
|
464
|
+
if (!challenge || !loginChallenges.take(challenge)) {
|
|
465
|
+
res.status(400).json({ error: 'The challenge is missing or expired; try again.' });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const state = (0, vault_1.loadVault)(root);
|
|
469
|
+
if (state.status !== 'ok') {
|
|
470
|
+
res.status(500).json({ error: 'The vault is not available; try again later.' });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const found = (0, vault_1.findPasskey)(state.vault, (0, web_1.field)(req, 'id'));
|
|
474
|
+
if (!found) {
|
|
475
|
+
authLimiter.fail(req, null);
|
|
476
|
+
res.status(401).json({ error: 'This passkey is not registered on this vault.' });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// The authenticator says whose credential it presented; when it does, it
|
|
480
|
+
// must agree with where the credential id was found.
|
|
481
|
+
const handle = (0, web_1.field)(req, 'userHandle');
|
|
482
|
+
if (handle) {
|
|
483
|
+
let claimed;
|
|
484
|
+
try {
|
|
485
|
+
claimed = (0, webauthn_1.fromB64url)(handle).toString('utf8');
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
claimed = '';
|
|
489
|
+
}
|
|
490
|
+
if (claimed !== found.username) {
|
|
491
|
+
authLimiter.fail(req, null);
|
|
492
|
+
res.status(401).json({ error: 'The passkey does not match its account.' });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
let assertion;
|
|
497
|
+
try {
|
|
498
|
+
assertion = (0, webauthn_1.verifyAssertion)({
|
|
499
|
+
authenticatorData,
|
|
500
|
+
clientDataJSON,
|
|
501
|
+
signature,
|
|
502
|
+
publicKey: found.passkey.publicKey,
|
|
503
|
+
alg: found.passkey.alg,
|
|
504
|
+
challenge,
|
|
505
|
+
origin: originOf(req),
|
|
506
|
+
rpId: rpIdOf(req),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
authLimiter.fail(req, null);
|
|
511
|
+
res.status(401).json({ error: e instanceof webauthn_1.WebAuthnError ? e.message : 'The sign-in could not be verified.' });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// A counter that moved backwards means two devices hold this key, which
|
|
515
|
+
// resident passkeys do legitimately (they sync); most report 0 and skip
|
|
516
|
+
// this entirely. Only an actual regression from a counter-keeping key is
|
|
517
|
+
// refused.
|
|
518
|
+
if (assertion.counter > 0 && found.passkey.counter > 0 && assertion.counter <= found.passkey.counter) {
|
|
519
|
+
authLimiter.fail(req, null);
|
|
520
|
+
res.status(401).json({ error: 'The passkey presented an old signature counter; remove and re-register it.' });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (assertion.counter > 0)
|
|
524
|
+
(0, vault_1.setPasskeyCounter)(root, found.username, found.passkey.id, assertion.counter);
|
|
525
|
+
(0, session_1.setSessionCookie)(req, res, root, {
|
|
526
|
+
username: found.username,
|
|
527
|
+
user: found.user,
|
|
528
|
+
token: { hash: (0, vault_1.passkeyBinding)(found.passkey.id) },
|
|
529
|
+
});
|
|
530
|
+
res.json({ next: safeNext((0, web_1.field)(req, 'next')) });
|
|
531
|
+
});
|
|
532
|
+
// ---- sign in with GitHub ----
|
|
533
|
+
//
|
|
534
|
+
// The OAuth code flow, ending at the same setSessionCookie as every other
|
|
535
|
+
// way in. The state parameter is a one-time id from the same kind of store
|
|
536
|
+
// the WebAuthn challenges use, so a callback that was not started here, or
|
|
537
|
+
// is replayed, redeems nothing; an entry marked with `link` was started by
|
|
538
|
+
// a signed-in user's own CSRF-checked form and attaches the GitHub account
|
|
539
|
+
// to them instead of signing anyone in. The access token GitHub hands back
|
|
540
|
+
// is used for one request -- who is this -- and dropped. The session is
|
|
541
|
+
// bound to gh:<numeric id> and resolves against live vault.json on every
|
|
542
|
+
// request (authForBinding in src/vault.ts), so unlinking the account ends
|
|
543
|
+
// its sessions the way revoking a token does.
|
|
544
|
+
const githubStates = (0, onetime_1.createOneTimeStore)();
|
|
545
|
+
const GITHUB_STATE_TTL_MS = 10 * 60 * 1000;
|
|
546
|
+
const githubCallbackUrl = (req) => `${req.protocol}://${req.get('host')}/login/github/callback`;
|
|
547
|
+
app.get('/login/github', (req, res) => {
|
|
548
|
+
const next = safeNext(String(req.query.next ?? '/'));
|
|
549
|
+
if ((0, session_1.getViewer)(req, root)) {
|
|
550
|
+
res.redirect(next);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (!(0, githubauth_1.githubConfigured)(root)) {
|
|
554
|
+
(0, web_1.fail)(res, 404, 'Sign-in with GitHub is not configured on this vault.', null, '/login');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const state = githubStates.put({ next }, GITHUB_STATE_TTL_MS);
|
|
558
|
+
res.redirect((0, githubauth_1.githubAuthorizeUrl)((0, config_1.loadConfig)(root).auth.githubClientId, githubCallbackUrl(req), state));
|
|
559
|
+
});
|
|
560
|
+
// Linking starts from a POST on the account page, so the CSRF check has
|
|
561
|
+
// already vouched that the account's owner asked for it; the state entry
|
|
562
|
+
// carries their name to the callback. A restricted session may not link,
|
|
563
|
+
// for the passkey rule's reason: signing in with GitHub carries the user's
|
|
564
|
+
// full standing, so minting the way in from a session that does not have it
|
|
565
|
+
// would widen the token it came from.
|
|
566
|
+
app.post('/account/github', form, (req, res) => {
|
|
567
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
568
|
+
if (!viewer)
|
|
569
|
+
return;
|
|
570
|
+
if (restricted(viewer)) {
|
|
571
|
+
(0, web_1.fail)(res, 403, 'A session from a restricted token may not link a GitHub account.', viewer, '/account');
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (!(0, githubauth_1.githubConfigured)(root)) {
|
|
575
|
+
(0, web_1.fail)(res, 404, 'Sign-in with GitHub is not configured on this vault.', viewer, '/account');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const state = githubStates.put({ next: '/account', link: viewer.auth.username }, GITHUB_STATE_TTL_MS);
|
|
579
|
+
res.redirect((0, githubauth_1.githubAuthorizeUrl)((0, config_1.loadConfig)(root).auth.githubClientId, githubCallbackUrl(req), state));
|
|
580
|
+
});
|
|
581
|
+
app.get('/login/github/callback', (0, web_1.ah)(async (req, res) => {
|
|
582
|
+
// The shared sign-in limiter: a forged or replayed callback is a
|
|
583
|
+
// credential being guessed, like a mistyped token.
|
|
584
|
+
const allowed = authLimiter.allow(req, null);
|
|
585
|
+
if (!allowed.ok) {
|
|
586
|
+
res.setHeader('Retry-After', String(allowed.retryAfter));
|
|
587
|
+
(0, web_1.fail)(res, 429, 'Too many attempts from this address. Try again later.', null, '/login');
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const pending = githubStates.take(String(req.query.state ?? ''));
|
|
591
|
+
if (!pending) {
|
|
592
|
+
authLimiter.fail(req, null);
|
|
593
|
+
(0, web_1.fail)(res, 400, 'This sign-in attempt is stale or was not started here; start again from the sign-in page.', null, '/login');
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const code = String(req.query.code ?? '');
|
|
597
|
+
if (code === '') {
|
|
598
|
+
// GitHub answers a cancelled authorization with ?error=access_denied
|
|
599
|
+
// and no code: a choice, not a failure worth a limiter charge.
|
|
600
|
+
(0, web_1.fail)(res, 400, 'GitHub did not complete the sign-in.', null, '/login');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const clientId = (0, config_1.loadConfig)(root).auth.githubClientId;
|
|
604
|
+
const clientSecret = (0, githubauth_1.readGithubSecret)(root);
|
|
605
|
+
if (clientId === '' || clientSecret === null) {
|
|
606
|
+
(0, web_1.fail)(res, 404, 'Sign-in with GitHub is not configured on this vault.', null, '/login');
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const accessToken = await (0, githubauth_1.exchangeGithubCode)({
|
|
610
|
+
clientId,
|
|
611
|
+
clientSecret,
|
|
612
|
+
code,
|
|
613
|
+
redirectUri: githubCallbackUrl(req),
|
|
614
|
+
});
|
|
615
|
+
const account = accessToken === null ? null : await (0, githubauth_1.fetchGithubUser)(accessToken);
|
|
616
|
+
// The access token's one use has been made; nothing keeps it.
|
|
617
|
+
if (!account) {
|
|
618
|
+
(0, web_1.fail)(res, 502, 'GitHub did not confirm the sign-in; try again.', null, '/login');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (pending.link !== undefined) {
|
|
622
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
623
|
+
if (!viewer || viewer.auth.username !== pending.link || restricted(viewer)) {
|
|
624
|
+
(0, web_1.fail)(res, 403, 'The session that started linking is gone; sign in and link again from your account page.', null, '/account');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
(0, vault_1.linkGithub)(root, viewer.auth.username, account);
|
|
629
|
+
}
|
|
630
|
+
catch (e) {
|
|
631
|
+
(0, web_1.fail)(res, 409, e instanceof Error ? e.message : String(e), viewer, '/account');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
res.redirect(`/account?msg=${encodeURIComponent(`Linked GitHub account ${account.login}.`)}`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const outcome = (0, vault_1.resolveGithubSignIn)(root, account);
|
|
638
|
+
if (outcome.kind === 'refused') {
|
|
639
|
+
authLimiter.fail(req, null);
|
|
640
|
+
(0, web_1.fail)(res, 403, `The GitHub account ${account.login} is not authorized on this vault. An administrator can approve it, ` +
|
|
641
|
+
'or you can link it from your account page if you already have an account here.', null, '/login');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (outcome.kind === 'error') {
|
|
645
|
+
(0, web_1.fail)(res, 409, outcome.message, null, '/login');
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const state = (0, vault_1.loadVault)(root);
|
|
649
|
+
const auth = state.status === 'ok' ? (0, vault_1.authForBinding)(state.vault, outcome.username, (0, vault_1.githubBinding)(account.id)) : null;
|
|
650
|
+
if (!auth) {
|
|
651
|
+
(0, web_1.fail)(res, 500, 'The vault is not available; try again later.', null, '/login');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
(0, session_1.setSessionCookie)(req, res, root, auth);
|
|
655
|
+
res.redirect(safeNext(pending.next));
|
|
656
|
+
}));
|
|
657
|
+
app.post('/account/github/unlink', form, (req, res) => {
|
|
658
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
659
|
+
if (!viewer)
|
|
660
|
+
return;
|
|
661
|
+
const linked = viewer.auth.user.github;
|
|
662
|
+
if (!linked) {
|
|
663
|
+
(0, web_1.fail)(res, 404, 'No GitHub account is linked.', viewer, '/account');
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
// An account whose GitHub link is its only credential would be stranded
|
|
667
|
+
// by unlinking -- and, if its GitHub id is still on the approved list, a
|
|
668
|
+
// later sign-in would mint a second account beside the orphan. Refused
|
|
669
|
+
// with the way out named instead.
|
|
670
|
+
if (viewer.auth.user.tokens.length === 0 && !viewer.auth.user.passkeys?.length) {
|
|
671
|
+
(0, web_1.fail)(res, 409, 'This GitHub link is the only way this account can sign in. Add a passkey, or have an administrator mint a token, before unlinking it.', viewer, '/account');
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const wasThisSession = viewer.auth.token.hash === (0, vault_1.githubBinding)(linked.id);
|
|
675
|
+
(0, vault_1.unlinkGithub)(root, viewer.auth.username);
|
|
676
|
+
// Unlinking the credential this session signed in with ends this session;
|
|
677
|
+
// the redirect says so instead of pretending otherwise.
|
|
678
|
+
if (wasThisSession) {
|
|
679
|
+
res.redirect('/login');
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
res.redirect(`/account?msg=${encodeURIComponent('GitHub account unlinked.')}`);
|
|
683
|
+
});
|
|
684
|
+
// ---- the signed-in user's own profile ----
|
|
685
|
+
// The profile a user's page at /<username> shows. The name "settings" is
|
|
686
|
+
// reserved as a collection name, so this path can never shadow one, and the
|
|
687
|
+
// route registers before the generic /:collection/:repo browse routes.
|
|
688
|
+
app.get('/settings/profile', (req, res) => {
|
|
689
|
+
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
690
|
+
if (!viewer)
|
|
691
|
+
return;
|
|
692
|
+
const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
|
|
693
|
+
res.type('html').send(forms.profileSettingsPage(viewer, viewer.auth.user.profile, msg));
|
|
694
|
+
});
|
|
695
|
+
app.post('/settings/profile', form, (req, res) => {
|
|
696
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
697
|
+
if (!viewer)
|
|
698
|
+
return;
|
|
699
|
+
const rerender = (status, error) => {
|
|
700
|
+
res.status(status).type('html').send(forms.profileSettingsPage(viewer, viewer.auth.user.profile, undefined, error));
|
|
701
|
+
};
|
|
702
|
+
const displayName = (0, web_1.field)(req, 'displayName').trim();
|
|
703
|
+
const bio = (0, web_1.field)(req, 'bio').trim();
|
|
704
|
+
if (displayName.length > 80 || /[\r\n]/.test(displayName)) {
|
|
705
|
+
rerender(400, 'The display name must be one line of at most 80 characters.');
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (bio.length > 500) {
|
|
709
|
+
rerender(400, 'The bio must be at most 500 characters.');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const links = (0, web_1.field)(req, 'links')
|
|
713
|
+
.split(/\r?\n/)
|
|
714
|
+
.map((l) => l.trim())
|
|
715
|
+
.filter((l) => l !== '');
|
|
716
|
+
if (links.length > 5) {
|
|
717
|
+
rerender(400, 'At most five links.');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// http(s) only: these render as hyperlinks on a page other people read,
|
|
721
|
+
// and nothing else belongs in an href there.
|
|
722
|
+
const bad = links.find((l) => l.length > 200 || !/^https?:\/\/\S+$/i.test(l));
|
|
723
|
+
if (bad !== undefined) {
|
|
724
|
+
rerender(400, `That does not look like an http(s) URL: ${bad}`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
(0, vault_1.setUserProfile)(root, viewer.auth.username, { name: displayName, bio, links });
|
|
728
|
+
res.redirect(`/settings/profile?msg=${encodeURIComponent('Profile saved.')}`);
|
|
729
|
+
});
|
|
184
730
|
// ---- new repository, new collection, import ----
|
|
185
731
|
// Importing is a client-side operation: git copies the repository from its
|
|
186
732
|
// current home and pushes it here, where push-to-create makes it. The server
|
|
@@ -265,6 +811,183 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
265
811
|
}
|
|
266
812
|
res.redirect(`/${encodeURIComponent(name)}`);
|
|
267
813
|
});
|
|
814
|
+
// ---- vault settings ----
|
|
815
|
+
//
|
|
816
|
+
// Registered ahead of the collection settings below, because the
|
|
817
|
+
// /:collection/settings pattern would otherwise swallow /admin/settings:
|
|
818
|
+
// 'admin' is a reserved name, so it can never be a collection, but the
|
|
819
|
+
// parameter route cannot know that and answers first in registration
|
|
820
|
+
// order. The helpers this block calls are declared in the administration
|
|
821
|
+
// section further down, which function hoisting makes reachable.
|
|
822
|
+
//
|
|
823
|
+
// The rest of config.json, administered from the browser: the sites
|
|
824
|
+
// hostname, CI retention, and the startup-read network and limits blocks.
|
|
825
|
+
// The JSON API refuses to write the startup-read ones, because an API caller
|
|
826
|
+
// reads the response as the new state of the server. A page can say more
|
|
827
|
+
// than a status code, so here they are editable, and the page names each
|
|
828
|
+
// saved value the running server is not yet using, with the command that
|
|
829
|
+
// restarts it.
|
|
830
|
+
// What the running server actually read: registration happens once, at
|
|
831
|
+
// startup, alongside server.ts reading the same file for its limiters, so
|
|
832
|
+
// this snapshot is the values the process is living by.
|
|
833
|
+
const startupConfig = (0, config_1.loadConfig)(root);
|
|
834
|
+
/**
|
|
835
|
+
* How this server is restarted, for the page's restart notes. The
|
|
836
|
+
* environment says which deployment this is: a Fly machine carries its app
|
|
837
|
+
* name, which names the exact command; a container carries /.dockerenv,
|
|
838
|
+
* which says only that it is one, so the command is the documented compose
|
|
839
|
+
* deployment's and is offered with that said. Null means nothing identifies
|
|
840
|
+
* a deployment, and the page says to start it again however it was started,
|
|
841
|
+
* which is the honest answer rather than a guess at somebody's unit file.
|
|
842
|
+
*/
|
|
843
|
+
function restartHow() {
|
|
844
|
+
const flyApp = process.env.FLY_APP_NAME;
|
|
845
|
+
if (flyApp)
|
|
846
|
+
return { command: `fly apps restart ${flyApp}`, caveat: '' };
|
|
847
|
+
if (fs.existsSync('/.dockerenv')) {
|
|
848
|
+
return {
|
|
849
|
+
command: 'docker compose restart mochi',
|
|
850
|
+
caveat: 'if this is the compose deployment; otherwise restart the container however it was started',
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
/** Back to the box the form was posted from, carrying its confirmation; see settingsBack above. */
|
|
856
|
+
function vaultSettingsBack(section, msg) {
|
|
857
|
+
return `/admin/settings?msg=${encodeURIComponent(msg)}&in=${section}#${section}`;
|
|
858
|
+
}
|
|
859
|
+
const VAULT_WIDE = 'Vault settings are vault-wide, so they take a site admin.';
|
|
860
|
+
app.get('/admin/settings', (req, res) => {
|
|
861
|
+
const viewer = requireAdminPage(req, res);
|
|
862
|
+
if (!viewer)
|
|
863
|
+
return;
|
|
864
|
+
if (!canSetVaultWide(viewer)) {
|
|
865
|
+
(0, web_1.fail)(res, 403, VAULT_WIDE, viewer, '/admin');
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
|
|
869
|
+
const inBox = typeof req.query.in === 'string' ? req.query.in : '';
|
|
870
|
+
const config = (0, config_1.loadConfig)(root);
|
|
871
|
+
res.type('html').send(forms.vaultSettingsPage(viewer, {
|
|
872
|
+
sitesHost: config.sites.host,
|
|
873
|
+
// What is already named under the sites host. Only gathered when
|
|
874
|
+
// there is one: the two scans are cheap but pointless on a vault
|
|
875
|
+
// serving its sites sandboxed on the forge host.
|
|
876
|
+
names: config.sites.host
|
|
877
|
+
? {
|
|
878
|
+
labels: (0, sitesettings_1.listSiteLabels)(root),
|
|
879
|
+
collections: (0, sitesettings_1.collectionSiteAliases)(root),
|
|
880
|
+
reserved: (0, siteshost_1.reservedSiteLabels)(),
|
|
881
|
+
}
|
|
882
|
+
: null,
|
|
883
|
+
ci: config.ci,
|
|
884
|
+
trustProxy: config.network.trustProxy,
|
|
885
|
+
limits: config.limits,
|
|
886
|
+
startup: { trustProxy: startupConfig.network.trustProxy, limits: startupConfig.limits },
|
|
887
|
+
restart: restartHow(),
|
|
888
|
+
}, msg, forms.isVaultSettingsSection(inBox) ? inBox : undefined));
|
|
889
|
+
});
|
|
890
|
+
/** A whole number from a form field, at least min, or null when it is not one. */
|
|
891
|
+
function intField(req, name, min) {
|
|
892
|
+
const raw = (0, web_1.field)(req, name).trim();
|
|
893
|
+
if (!/^\d{1,9}$/.test(raw))
|
|
894
|
+
return null;
|
|
895
|
+
const n = parseInt(raw, 10);
|
|
896
|
+
return n >= min ? n : null;
|
|
897
|
+
}
|
|
898
|
+
function requireVaultAdminPost(req, res) {
|
|
899
|
+
const viewer = requireAdminPost(req, res);
|
|
900
|
+
if (!viewer)
|
|
901
|
+
return null;
|
|
902
|
+
if (!canSetVaultWide(viewer)) {
|
|
903
|
+
(0, web_1.fail)(res, 403, VAULT_WIDE, viewer, '/admin');
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
return viewer;
|
|
907
|
+
}
|
|
908
|
+
app.post('/admin/settings/sites', form, (req, res) => {
|
|
909
|
+
const viewer = requireVaultAdminPost(req, res);
|
|
910
|
+
if (!viewer)
|
|
911
|
+
return;
|
|
912
|
+
const host = (0, siteshost_1.normalizeHostname)((0, web_1.field)(req, 'host'));
|
|
913
|
+
// loadConfig ignores a value that is not a hostname and uses the default,
|
|
914
|
+
// which is right for a hand-edited file and wrong here: refuse the typo
|
|
915
|
+
// with a message, as the API does, rather than store it silently inert.
|
|
916
|
+
if (host !== '' && !(0, config_1.isPlausibleHostname)(host)) {
|
|
917
|
+
(0, web_1.fail)(res, 400, `${(0, web_1.field)(req, 'host').trim()} is not a hostname: give at least two labels of letters, digits, and interior hyphens, with no scheme, port, or path.`, viewer, '/admin/settings');
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
(0, config_1.saveConfig)(root, { sites: { host } });
|
|
921
|
+
const msg = host
|
|
922
|
+
? `Sites are served from their own hostnames under ${host}, from the next request on.`
|
|
923
|
+
: 'Sites are served on the forge host, sandboxed, from the next request on.';
|
|
924
|
+
res.redirect(vaultSettingsBack('sites', msg));
|
|
925
|
+
});
|
|
926
|
+
app.post('/admin/settings/ci', form, (req, res) => {
|
|
927
|
+
const viewer = requireVaultAdminPost(req, res);
|
|
928
|
+
if (!viewer)
|
|
929
|
+
return;
|
|
930
|
+
const runs = intField(req, 'runs', 0);
|
|
931
|
+
const days = intField(req, 'days', 0);
|
|
932
|
+
const artifactMb = intField(req, 'artifactMb', 1);
|
|
933
|
+
if (runs === null || days === null || artifactMb === null) {
|
|
934
|
+
(0, web_1.fail)(res, 400, 'Runs and days are whole numbers, 0 disabling the age limit, and the artifact size is at least 1 MB.', viewer, '/admin/settings');
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
(0, config_1.saveConfig)(root, { ci: { runs, days, artifactMb } });
|
|
938
|
+
res.redirect(vaultSettingsBack('ci', `CI retention saved: keep ${runs} runs${days > 0 ? ` and ${days} days` : ''}, artifacts up to ${artifactMb} MB.`));
|
|
939
|
+
});
|
|
940
|
+
app.post('/admin/settings/network', form, (req, res) => {
|
|
941
|
+
const viewer = requireVaultAdminPost(req, res);
|
|
942
|
+
if (!viewer)
|
|
943
|
+
return;
|
|
944
|
+
const value = (0, web_1.field)(req, 'trustProxy');
|
|
945
|
+
if (value !== 'true' && value !== 'false') {
|
|
946
|
+
(0, web_1.fail)(res, 400, 'Say whether the forwarded headers are believed or not.', viewer, '/admin/settings');
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const trustProxy = value === 'true';
|
|
950
|
+
(0, config_1.saveConfig)(root, { network: { trustProxy } });
|
|
951
|
+
res.redirect(vaultSettingsBack('network', trustProxy === startupConfig.network.trustProxy
|
|
952
|
+
? `Saved: forwarded headers ${trustProxy ? 'believed' : 'not believed'}, which is what the running server started with.`
|
|
953
|
+
: 'Saved to config.json; the running server keeps its startup value until it restarts.'));
|
|
954
|
+
});
|
|
955
|
+
app.post('/admin/settings/limits', form, (req, res) => {
|
|
956
|
+
const viewer = requireVaultAdminPost(req, res);
|
|
957
|
+
if (!viewer)
|
|
958
|
+
return;
|
|
959
|
+
const requestsPerMinute = intField(req, 'requestsPerMinute', 0);
|
|
960
|
+
const authFailures = intField(req, 'authFailures', 0);
|
|
961
|
+
const clone = intField(req, 'clone', 1);
|
|
962
|
+
const push = intField(req, 'push', 1);
|
|
963
|
+
const search = intField(req, 'search', 1);
|
|
964
|
+
const tree = intField(req, 'tree', 1);
|
|
965
|
+
if (requestsPerMinute === null ||
|
|
966
|
+
authFailures === null ||
|
|
967
|
+
clone === null ||
|
|
968
|
+
push === null ||
|
|
969
|
+
search === null ||
|
|
970
|
+
tree === null) {
|
|
971
|
+
(0, web_1.fail)(res, 400, 'Limits are whole numbers: 0 disables a rate limit, and each concurrency is at least 1.', viewer, '/admin/settings');
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// The whole block is written back, since saveConfig replaces a top-level
|
|
975
|
+
// key rather than merging into it; the egress cap keeps the value it has,
|
|
976
|
+
// because its lever is the Egress page.
|
|
977
|
+
(0, config_1.saveConfig)(root, {
|
|
978
|
+
limits: { ...(0, config_1.loadConfig)(root).limits, requestsPerMinute, authFailures, clone, push, search, tree },
|
|
979
|
+
});
|
|
980
|
+
const s = startupConfig.limits;
|
|
981
|
+
const applied = requestsPerMinute === s.requestsPerMinute &&
|
|
982
|
+
authFailures === s.authFailures &&
|
|
983
|
+
clone === s.clone &&
|
|
984
|
+
push === s.push &&
|
|
985
|
+
search === s.search &&
|
|
986
|
+
tree === s.tree;
|
|
987
|
+
res.redirect(vaultSettingsBack('limits', applied
|
|
988
|
+
? 'Limits saved, matching what the running server started with.'
|
|
989
|
+
: 'Limits saved to config.json; values the running server read at startup keep applying until it restarts.'));
|
|
990
|
+
});
|
|
268
991
|
// ---- a collection's own settings ----
|
|
269
992
|
//
|
|
270
993
|
// These sit at /:collection/settings, one segment shallower than a
|
|
@@ -300,6 +1023,22 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
300
1023
|
}
|
|
301
1024
|
return { name, repos };
|
|
302
1025
|
}
|
|
1026
|
+
/**
|
|
1027
|
+
* What the collection's Site alias box shows: which tier its alias comes
|
|
1028
|
+
* from, and, when the rewritten label is unavailable, who holds it. The
|
|
1029
|
+
* resolution is the vault's, so it is done here rather than in the page.
|
|
1030
|
+
*/
|
|
1031
|
+
function collectionSites(collection) {
|
|
1032
|
+
const row = (0, sitesettings_1.collectionSiteAliases)(root).find((r) => r.collection === collection);
|
|
1033
|
+
const taken = row?.taken ?? '';
|
|
1034
|
+
return {
|
|
1035
|
+
host: (0, config_1.loadConfig)(root).sites.host,
|
|
1036
|
+
stored: (0, sitesettings_1.storedCollectionAlias)(root, collection),
|
|
1037
|
+
alias: row?.alias ?? null,
|
|
1038
|
+
taken,
|
|
1039
|
+
takenBy: taken ? ((0, sitesettings_1.collectionAliasConflict)(root, taken, collection) ?? '') : '',
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
303
1042
|
app.get('/:collection/settings', (req, res) => {
|
|
304
1043
|
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
305
1044
|
if (!viewer)
|
|
@@ -310,7 +1049,43 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
310
1049
|
const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
|
|
311
1050
|
res
|
|
312
1051
|
.type('html')
|
|
313
|
-
.send(forms.collectionSettingsPage(viewer, loaded.name, loaded.repos.length, (0, perms_1.collectionOwners)(root, loaded.name), msg));
|
|
1052
|
+
.send(forms.collectionSettingsPage(viewer, loaded.name, loaded.repos.length, (0, perms_1.collectionOwners)(root, loaded.name), collectionSites(loaded.name), msg));
|
|
1053
|
+
});
|
|
1054
|
+
/**
|
|
1055
|
+
* Store or clear the collection's site alias. Ownership of the collection is
|
|
1056
|
+
* the whole question, as it is for the owners list and the rename: the alias
|
|
1057
|
+
* decides the hostnames of the sites in this collection and nothing else.
|
|
1058
|
+
* The label it claims is checked against every other collection's, so an
|
|
1059
|
+
* owner cannot take a name another collection is already reached by.
|
|
1060
|
+
*/
|
|
1061
|
+
app.post('/:collection/settings/site', form, (req, res) => {
|
|
1062
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
1063
|
+
if (!viewer)
|
|
1064
|
+
return;
|
|
1065
|
+
const loaded = loadCollection(req, res, viewer, true);
|
|
1066
|
+
if (!loaded)
|
|
1067
|
+
return;
|
|
1068
|
+
const backUrl = `/${encodeURIComponent(loaded.name)}/settings`;
|
|
1069
|
+
const alias = (0, web_1.field)(req, 'alias').trim();
|
|
1070
|
+
if (alias !== '' && !(0, sitesettings_1.isUsableCollectionAlias)(alias)) {
|
|
1071
|
+
(0, web_1.fail)(res, 400, 'A site alias is lowercase letters, digits, and single interior hyphens, with no doubled hyphen, at most 63 characters.', viewer, backUrl);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (alias !== '') {
|
|
1075
|
+
const holder = (0, sitesettings_1.collectionAliasConflict)(root, alias, loaded.name);
|
|
1076
|
+
if (holder) {
|
|
1077
|
+
(0, web_1.fail)(res, 409, `The alias ${alias} is already how the collection ${holder} is reached.`, viewer, backUrl);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
(0, sitesettings_1.setCollectionAlias)(root, loaded.name, alias);
|
|
1082
|
+
const effective = (0, sitesettings_1.collectionSiteAlias)(root, loaded.name);
|
|
1083
|
+
const msg = alias === ''
|
|
1084
|
+
? effective
|
|
1085
|
+
? `Alias cleared; the collection is reached as ${effective}.`
|
|
1086
|
+
: 'Alias cleared; the collection has no derived hostname.'
|
|
1087
|
+
: `Sites in this collection are now served under ${alias}.`;
|
|
1088
|
+
res.redirect(`${backUrl}?msg=${encodeURIComponent(msg)}`);
|
|
314
1089
|
});
|
|
315
1090
|
app.post('/:collection/settings/rename', form, (0, web_1.ah)(async (req, res) => {
|
|
316
1091
|
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
@@ -380,6 +1155,32 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
380
1155
|
(0, perms_1.removeCollectionOwner)(root, loaded.name, username);
|
|
381
1156
|
res.redirect(`${backUrl}?msg=${encodeURIComponent(`Removed ${username} from the owners of ${loaded.name}.`)}`);
|
|
382
1157
|
});
|
|
1158
|
+
// Deletion, confirmed by typing the name as a repository's is. Ownership,
|
|
1159
|
+
// which loadCollection has established, is the whole permission; whether the
|
|
1160
|
+
// collection is empty is the operation's own check, so a repository pushed
|
|
1161
|
+
// between loading the page and clicking the button is a refusal, not a loss.
|
|
1162
|
+
app.post('/:collection/settings/delete', form, (req, res) => {
|
|
1163
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
1164
|
+
if (!viewer)
|
|
1165
|
+
return;
|
|
1166
|
+
const loaded = loadCollection(req, res, viewer, true);
|
|
1167
|
+
if (!loaded)
|
|
1168
|
+
return;
|
|
1169
|
+
const backUrl = `/${encodeURIComponent(loaded.name)}/settings`;
|
|
1170
|
+
if ((0, web_1.field)(req, 'confirm').trim() !== loaded.name) {
|
|
1171
|
+
(0, web_1.fail)(res, 400, `Type ${loaded.name} exactly to confirm deletion.`, viewer, backUrl);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
try {
|
|
1175
|
+
ops.deleteCollection(root, loaded.name);
|
|
1176
|
+
}
|
|
1177
|
+
catch (e) {
|
|
1178
|
+
const message = e instanceof ops_1.OpError ? e.message : 'Could not delete the collection.';
|
|
1179
|
+
(0, web_1.fail)(res, e instanceof ops_1.OpError ? (0, ops_1.opErrorStatus)(e.kind) : 400, message, viewer, backUrl);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
res.redirect('/');
|
|
1183
|
+
});
|
|
383
1184
|
app.get('/new', (req, res) => {
|
|
384
1185
|
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
385
1186
|
if (!viewer)
|
|
@@ -491,6 +1292,41 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
491
1292
|
}
|
|
492
1293
|
throw e;
|
|
493
1294
|
}
|
|
1295
|
+
// The markdown editor's Preview tab posts a draft here and gets it back
|
|
1296
|
+
// rendered. The route is repo-scoped so relative links, `#12`, and commit
|
|
1297
|
+
// ids resolve exactly as they will on the saved page, and it sits behind the
|
|
1298
|
+
// same session and CSRF check as the write being drafted: the renderer
|
|
1299
|
+
// sanitizes what it is given, but there is no reason to render for anyone
|
|
1300
|
+
// who could not save.
|
|
1301
|
+
app.post('/:collection/:repo/preview', form, (0, web_1.ah)(async (req, res) => {
|
|
1302
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
1303
|
+
if (!viewer)
|
|
1304
|
+
return;
|
|
1305
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
1306
|
+
if (!loaded)
|
|
1307
|
+
return;
|
|
1308
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
1309
|
+
const text = (0, web_1.field)(req, 'text');
|
|
1310
|
+
// The ref and directory are the draft's claim about where it sits: an
|
|
1311
|
+
// unknown ref falls back to the default branch, and a directory is only
|
|
1312
|
+
// honoured when it is a plain relative path.
|
|
1313
|
+
const asked = (0, web_1.field)(req, 'ref');
|
|
1314
|
+
const ref = asked !== '' && loaded.refNames.includes(asked) ? asked : ctx.defaultBranch || ctx.ref || 'HEAD';
|
|
1315
|
+
const dirAsked = (0, web_1.field)(req, 'dir');
|
|
1316
|
+
const dir = dirAsked !== '' && dirAsked.split('/').every((s) => s !== '' && s !== '.' && s !== '..') ? dirAsked : '';
|
|
1317
|
+
const base = urlOf(loaded.repo);
|
|
1318
|
+
const at = (kind) => `${base}/${kind}/${(0, views_1.encPath)(ref)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
|
|
1319
|
+
const rendered = text.trim() === ''
|
|
1320
|
+
? '<p class="muted">Nothing to preview.</p>'
|
|
1321
|
+
: (0, markdown_1.renderMarkdown)(text, {
|
|
1322
|
+
rawBase: at('raw'),
|
|
1323
|
+
blobBase: at('blob'),
|
|
1324
|
+
issueBase: `${base}/issues`,
|
|
1325
|
+
commitBase: `${base}/commit`,
|
|
1326
|
+
mentions: ctx.hasUser,
|
|
1327
|
+
});
|
|
1328
|
+
res.type('html').send(rendered);
|
|
1329
|
+
}));
|
|
494
1330
|
app.get('/:collection/:repo/edit/*', (0, web_1.ah)(async (req, res) => {
|
|
495
1331
|
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
496
1332
|
if (!viewer)
|
|
@@ -499,11 +1335,18 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
499
1335
|
if (!target)
|
|
500
1336
|
return;
|
|
501
1337
|
const { loaded, branch, filePath } = target;
|
|
502
|
-
const
|
|
503
|
-
if (
|
|
1338
|
+
const info = await loaded.repo.blobInfo(branch, filePath);
|
|
1339
|
+
if (!info) {
|
|
504
1340
|
(0, web_1.send404)(res, `File ${filePath} not found at ${branch}`, viewer);
|
|
505
1341
|
return;
|
|
506
1342
|
}
|
|
1343
|
+
// From the size alone, before the file is read: the checks below say
|
|
1344
|
+
// the same thing after reading it, and a file the size of the machine
|
|
1345
|
+
// should not be read to be told it is too big for a text box.
|
|
1346
|
+
if (info.size > ops_1.MAX_EDIT_SIZE) {
|
|
1347
|
+
(0, web_1.fail)(res, 400, 'Only text files up to 1 MB can be edited in the browser.', viewer, urlOf(loaded.repo));
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
507
1350
|
const buf = await loaded.repo.catBlob(branch, filePath);
|
|
508
1351
|
// Editing a pointer as text would silently corrupt the repository's
|
|
509
1352
|
// LFS state, so it is refused outright (deletion remains allowed).
|
|
@@ -511,6 +1354,19 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
511
1354
|
(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
1355
|
return;
|
|
513
1356
|
}
|
|
1357
|
+
// An age file gets the decrypt-in-the-browser editor, whichever framing
|
|
1358
|
+
// it is in: the ciphertext may be binary, but what the editor holds and
|
|
1359
|
+
// what the commit posts are text. The size cap still applies, since the
|
|
1360
|
+
// posted ciphertext travels the same form as any other edit.
|
|
1361
|
+
if ((0, agefile_1.isAgeFile)(filePath)) {
|
|
1362
|
+
if (buf.length > ops_1.MAX_EDIT_SIZE) {
|
|
1363
|
+
(0, web_1.fail)(res, 400, 'Only files up to 1 MB can be edited in the browser.', viewer, urlOf(loaded.repo));
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, branch, viewer);
|
|
1367
|
+
res.type('html').send(forms.editAgeFilePage(ctx, filePath, target.tip));
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
514
1370
|
if ((0, render_1.isBinary)(buf) || buf.length > ops_1.MAX_EDIT_SIZE) {
|
|
515
1371
|
(0, web_1.fail)(res, 400, 'Only text files up to 1 MB can be edited in the browser.', viewer, urlOf(loaded.repo));
|
|
516
1372
|
return;
|
|
@@ -532,15 +1388,35 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
532
1388
|
return;
|
|
533
1389
|
}
|
|
534
1390
|
const content = normalizeContent((0, web_1.field)(req, 'content'));
|
|
1391
|
+
const retryUrl = `${urlOf(loaded.repo)}/edit/${(0, views_1.encPath)(branch)}/${(0, views_1.encPath)(filePath)}`;
|
|
1392
|
+
// The editor only opens files up to MAX_EDIT_SIZE, so content beyond it
|
|
1393
|
+
// means a paste that outgrew what this form is for. Checked here so the
|
|
1394
|
+
// refusal names the way that does work, instead of the body limit 413ing
|
|
1395
|
+
// with no advice.
|
|
1396
|
+
if (Buffer.byteLength(content, 'utf8') > ops_1.MAX_EDIT_SIZE) {
|
|
1397
|
+
(0, web_1.fail)(res, 413, 'Files over 1 MB cannot be committed through the editor. Use the Upload files form, or push with git.', viewer, retryUrl);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
535
1400
|
// 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
|
-
|
|
1401
|
+
// what the path field on the form is for. Rejected rather than silently
|
|
1402
|
+
// rewritten when it is not a path this repository can take.
|
|
1403
|
+
const wantedPath = (0, web_1.field)(req, 'path').trim();
|
|
538
1404
|
const toPath = wantedPath === '' || wantedPath === filePath ? undefined : wantedPath;
|
|
539
|
-
|
|
540
|
-
if (toPath !== undefined && !(0, git_1.isValidRepoPath)(toPath)) {
|
|
1405
|
+
if (toPath !== undefined && !(0, git_1.isValidNewRepoPath)(toPath)) {
|
|
541
1406
|
(0, web_1.fail)(res, 400, `${toPath} is not a usable path.`, viewer, retryUrl);
|
|
542
1407
|
return;
|
|
543
1408
|
}
|
|
1409
|
+
// What stands between a failed script and a plaintext commit: a .age
|
|
1410
|
+
// path only ever receives age-shaped bytes through this form. The
|
|
1411
|
+
// browser editor encrypts before posting, so ordinary use never sees
|
|
1412
|
+
// this; a page where that script did not run must not quietly write
|
|
1413
|
+
// the secret it was meant to protect. Byte-exact writes that are not
|
|
1414
|
+
// age files remain possible over git and the API, where nothing is
|
|
1415
|
+
// encrypting on the caller's behalf.
|
|
1416
|
+
if ((0, agefile_1.isAgeFile)(toPath ?? filePath) && !(0, agefile_1.looksLikeAge)(Buffer.from(content, 'utf8'))) {
|
|
1417
|
+
(0, web_1.fail)(res, 400, 'A .age file holds an age ciphertext, and this content is not one. The editor encrypts in the browser before committing; committing this text as-is would store it unencrypted under a name that promises otherwise. To write these bytes anyway, use git or the API.', viewer, retryUrl);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
544
1420
|
const message = commitMessage(req, toPath === undefined ? `Update ${filePath.split('/').pop()}` : `Rename ${filePath} to ${toPath}`);
|
|
545
1421
|
// The branch choice, the pointer refusal, and the guarded commit are one
|
|
546
1422
|
// sequence in ops.writeFile, which the API write path calls too. The GET
|
|
@@ -629,15 +1505,21 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
629
1505
|
}
|
|
630
1506
|
// Browsers send the name the file had on disk, and some send a whole
|
|
631
1507
|
// path; only the last segment is ours to use, and it still has to be a
|
|
632
|
-
// path this repository would accept.
|
|
1508
|
+
// path this repository would accept. When the last segment is not the
|
|
1509
|
+
// name that arrived, the reader is told what the file was saved as,
|
|
1510
|
+
// rather than the flattening happening in silence.
|
|
633
1511
|
const files = [];
|
|
1512
|
+
const renamed = [];
|
|
634
1513
|
for (const upload of uploads) {
|
|
635
|
-
const
|
|
1514
|
+
const sent = upload.filename ?? '';
|
|
1515
|
+
const name = sent.split(/[\\/]/).pop() ?? '';
|
|
636
1516
|
const full = dir === '' ? name : `${dir}/${name}`;
|
|
637
|
-
if (name === '' || name === '.' || name === '..' || !(0, git_1.
|
|
1517
|
+
if (name === '' || name === '.' || name === '..' || !(0, git_1.isValidNewRepoPath)(full)) {
|
|
638
1518
|
(0, web_1.fail)(res, 400, `${name || 'That file'} does not have a usable name.`, viewer, retryUrl);
|
|
639
1519
|
return;
|
|
640
1520
|
}
|
|
1521
|
+
if (name !== sent)
|
|
1522
|
+
renamed.push(`${sent} was saved as ${name}`);
|
|
641
1523
|
files.push({ path: full, content: upload.data });
|
|
642
1524
|
}
|
|
643
1525
|
const summary = (0, multipart_1.partField)(parts, 'message').trim();
|
|
@@ -679,7 +1561,8 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
679
1561
|
res.redirect(`${urlOf(loaded.repo)}/compare/${(0, views_1.encPath)(branch)}...${(0, views_1.encPath)(onto)}`);
|
|
680
1562
|
return;
|
|
681
1563
|
}
|
|
682
|
-
|
|
1564
|
+
const note = renamed.length ? `?msg=${encodeURIComponent(renamed.join('; ') + '.')}` : '';
|
|
1565
|
+
res.redirect(`${urlOf(loaded.repo)}/tree/${(0, views_1.encPath)(onto)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}${note}`);
|
|
683
1566
|
}));
|
|
684
1567
|
app.get('/:collection/:repo/new/*', (0, web_1.ah)(async (req, res) => {
|
|
685
1568
|
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
@@ -712,14 +1595,28 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
712
1595
|
await handleOpError(new ops_1.OpError('branch created meanwhile', 'conflict'), req, res, viewer, target, req.originalUrl);
|
|
713
1596
|
return;
|
|
714
1597
|
}
|
|
715
|
-
|
|
1598
|
+
// Rejected rather than silently de-rooted or flattened: a leading slash,
|
|
1599
|
+
// a backslash, or a .git component is a name the commit cannot take, and
|
|
1600
|
+
// rewriting it would commit a path nobody typed.
|
|
1601
|
+
const filename = (0, web_1.field)(req, 'filename').trim();
|
|
716
1602
|
const fullPath = dir === '' ? filename : `${dir}/${filename}`;
|
|
717
1603
|
const retryUrl = req.originalUrl;
|
|
718
|
-
if (filename === '' || !(0, git_1.
|
|
1604
|
+
if (filename === '' || !(0, git_1.isValidNewRepoPath)(fullPath)) {
|
|
719
1605
|
(0, web_1.fail)(res, 400, 'Invalid file name.', viewer, retryUrl);
|
|
720
1606
|
return;
|
|
721
1607
|
}
|
|
722
1608
|
const content = normalizeContent((0, web_1.field)(req, 'content'));
|
|
1609
|
+
// See the edit form: content past what the editor itself would open is
|
|
1610
|
+
// refused with advice rather than left to the body limit.
|
|
1611
|
+
if (Buffer.byteLength(content, 'utf8') > ops_1.MAX_EDIT_SIZE) {
|
|
1612
|
+
(0, web_1.fail)(res, 413, 'Files over 1 MB cannot be committed through the editor. Use the Upload files form, or push with git.', viewer, retryUrl);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
// The same backstop the edit form has: see the comment there.
|
|
1616
|
+
if ((0, agefile_1.isAgeFile)(fullPath) && !(0, agefile_1.looksLikeAge)(Buffer.from(content, 'utf8'))) {
|
|
1617
|
+
(0, web_1.fail)(res, 400, 'A .age file holds an age ciphertext, and this content is not one. The form encrypts in the browser before committing; committing this text as-is would store it unencrypted under a name that promises otherwise. To write these bytes anyway, use git or the API.', viewer, retryUrl);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
723
1620
|
const message = commitMessage(req, `Create ${filename.split('/').pop()}`);
|
|
724
1621
|
let written;
|
|
725
1622
|
try {
|
|
@@ -903,11 +1800,32 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
903
1800
|
return;
|
|
904
1801
|
}
|
|
905
1802
|
const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
|
|
1803
|
+
// Which box the message belongs in, so a save is confirmed where the
|
|
1804
|
+
// form was rather than at the top of a page the reader has been thrown
|
|
1805
|
+
// back to. A value naming no box reads as none, like an unknown sort.
|
|
1806
|
+
const inParam = typeof req.query.in === 'string' ? req.query.in : '';
|
|
1807
|
+
const section = forms.isSettingsSection(inParam) ? inParam : undefined;
|
|
906
1808
|
const access = (0, perms_1.repoAccess)(loaded.repo.dir);
|
|
907
|
-
|
|
1809
|
+
const settings = (0, sitesettings_1.siteSettings)(loaded.repo.dir);
|
|
1810
|
+
const sitesHost = (0, config_1.loadConfig)(root).sites.host;
|
|
1811
|
+
// The label the site would be served under with nothing set: the whole
|
|
1812
|
+
// hostname minus the sites host and the dot joining them.
|
|
1813
|
+
const derivedHost = (0, sitesettings_1.siteHostFor)(root, sitesHost, loaded.repo.collection, loaded.repo.name);
|
|
1814
|
+
const alias = (0, sitesettings_1.collectionSiteAlias)(root, loaded.repo.collection);
|
|
1815
|
+
res.type('html').send(forms.settingsPage(ctx, (0, scan_1.repoDescription)(loaded.repo.dir) ?? '', (0, topics_1.repoTopics)(loaded.repo.dir), {
|
|
908
1816
|
collaborators: Object.entries(access.collaborators).map(([username, role]) => ({ username, role })),
|
|
909
1817
|
owners: (0, perms_1.collectionOwners)(root, loaded.repo.collection),
|
|
910
|
-
},
|
|
1818
|
+
}, {
|
|
1819
|
+
enabled: settings.enabled,
|
|
1820
|
+
source: settings.source,
|
|
1821
|
+
path: settings.path,
|
|
1822
|
+
label: settings.label,
|
|
1823
|
+
domain: (0, domains_1.repoDomain)(root, loaded.repo.collection, loaded.repo.name),
|
|
1824
|
+
sitesHost,
|
|
1825
|
+
derivedLabel: derivedHost ? derivedHost.slice(0, -(sitesHost.length + 1)) : null,
|
|
1826
|
+
collectionAlias: alias,
|
|
1827
|
+
dirExists: (0, scan_1.siteDir)(root, loaded.repo.collection, loaded.repo.name) !== null,
|
|
1828
|
+
}, msg, undefined, section));
|
|
911
1829
|
}));
|
|
912
1830
|
app.post('/:collection/:repo/settings', form, (0, web_1.ah)(async (req, res) => {
|
|
913
1831
|
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
@@ -916,12 +1834,32 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
916
1834
|
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
917
1835
|
if (!loaded)
|
|
918
1836
|
return;
|
|
919
|
-
const backUrl =
|
|
1837
|
+
const backUrl = settingsBack(loaded.repo, 'general');
|
|
920
1838
|
if (!(0, perms_1.atLeast)(loaded.role, 'write')) {
|
|
921
1839
|
(0, web_1.fail)(res, 403, `You do not have the write role on ${loaded.repo.collection}/${loaded.repo.name}.`, viewer, backUrl);
|
|
922
1840
|
return;
|
|
923
1841
|
}
|
|
924
|
-
|
|
1842
|
+
try {
|
|
1843
|
+
ops.setDescription(loaded.repo.dir, (0, web_1.field)(req, 'description'));
|
|
1844
|
+
// Only when the form carried the field: the General form always does,
|
|
1845
|
+
// but treating an absent field as "no topics" would let any other
|
|
1846
|
+
// post to this route silently clear them.
|
|
1847
|
+
if (req.body.topics !== undefined) {
|
|
1848
|
+
(0, topics_1.setTopics)(loaded.repo.dir, (0, topics_1.parseTopicsInput)((0, web_1.field)(req, 'topics')));
|
|
1849
|
+
}
|
|
1850
|
+
// Same guard as topics: an absent field means an older or other form,
|
|
1851
|
+
// not a request to clear the upstream.
|
|
1852
|
+
if (req.body.upstream !== undefined) {
|
|
1853
|
+
await ops.setUpstream(loaded.repo.dir, (0, web_1.field)(req, 'upstream').trim());
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
catch (e) {
|
|
1857
|
+
if (e instanceof ops_1.OpError) {
|
|
1858
|
+
(0, web_1.fail)(res, (0, ops_1.opErrorStatus)(e.kind), e.message, viewer, backUrl);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
throw e;
|
|
1862
|
+
}
|
|
925
1863
|
const defaultBranch = (0, web_1.field)(req, 'defaultBranch');
|
|
926
1864
|
if (defaultBranch !== '' && defaultBranch !== loaded.defaultBranch) {
|
|
927
1865
|
if (!loaded.branches.some((b) => b.name === defaultBranch)) {
|
|
@@ -929,12 +1867,62 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
929
1867
|
return;
|
|
930
1868
|
}
|
|
931
1869
|
await ops.setDefaultBranch(loaded.repo.dir, defaultBranch);
|
|
1870
|
+
// A site that follows the default branch now follows this one.
|
|
1871
|
+
await (0, sitepublish_1.publishSiteAfterPush)(root, loaded.repo, `refs/heads/${defaultBranch}`);
|
|
932
1872
|
}
|
|
933
|
-
res.redirect(
|
|
1873
|
+
res.redirect(settingsBack(loaded.repo, 'general', 'Settings saved.'));
|
|
1874
|
+
}));
|
|
1875
|
+
// Topics on their own, for the editor in the repository page's About panel:
|
|
1876
|
+
// a form that posted only topics to the route above would clear the
|
|
1877
|
+
// description on the way. Write role, the same as the description, since a
|
|
1878
|
+
// topic publishes nothing and withdraws nothing.
|
|
1879
|
+
app.post('/:collection/:repo/settings/topics', form, (0, web_1.ah)(async (req, res) => {
|
|
1880
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
1881
|
+
if (!viewer)
|
|
1882
|
+
return;
|
|
1883
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
1884
|
+
if (!loaded)
|
|
1885
|
+
return;
|
|
1886
|
+
// Back to where the form was: the About panel's editor says so, and
|
|
1887
|
+
// anything else lands on the settings page.
|
|
1888
|
+
const backUrl = (0, web_1.field)(req, 'next') === 'repo' ? urlOf(loaded.repo) : `${urlOf(loaded.repo)}/settings`;
|
|
1889
|
+
if (!(0, perms_1.atLeast)(loaded.role, 'write')) {
|
|
1890
|
+
(0, web_1.fail)(res, 403, `You do not have the write role on ${loaded.repo.collection}/${loaded.repo.name}.`, viewer, backUrl);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
(0, topics_1.setTopics)(loaded.repo.dir, (0, topics_1.parseTopicsInput)((0, web_1.field)(req, 'topics')));
|
|
1895
|
+
}
|
|
1896
|
+
catch (e) {
|
|
1897
|
+
if (e instanceof ops_1.OpError) {
|
|
1898
|
+
(0, web_1.fail)(res, (0, ops_1.opErrorStatus)(e.kind), e.message, viewer, backUrl);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
throw e;
|
|
1902
|
+
}
|
|
1903
|
+
const now = (0, topics_1.repoTopics)(loaded.repo.dir);
|
|
1904
|
+
const msg = now.length === 0 ? 'Topics cleared.' : `Topics saved: ${now.join(', ')}.`;
|
|
1905
|
+
// Topics live in the General box, so a post from the settings page is
|
|
1906
|
+
// confirmed there; one from the About panel goes back to the repository
|
|
1907
|
+
// page, which has no box to name.
|
|
1908
|
+
res.redirect((0, web_1.field)(req, 'next') === 'repo'
|
|
1909
|
+
? `${backUrl}?msg=${encodeURIComponent(msg)}`
|
|
1910
|
+
: settingsBack(loaded.repo, 'general', msg));
|
|
934
1911
|
}));
|
|
935
1912
|
// ---- visibility and collaborators ----
|
|
936
1913
|
// Both take the admin role: who may see or write a repository is decided by
|
|
937
1914
|
// the people who administer it, the same rule the JSON API applies.
|
|
1915
|
+
/**
|
|
1916
|
+
* Where a settings form sends the reader afterwards: back to the box it was
|
|
1917
|
+
* posted from, carrying its confirmation. The fragment is what scrolls
|
|
1918
|
+
* there, so a save no longer throws the reader to the top of the page, and
|
|
1919
|
+
* `in=` names the same box in the query because a fragment never reaches the
|
|
1920
|
+
* server and the page has to know which box to show the message in.
|
|
1921
|
+
*/
|
|
1922
|
+
function settingsBack(repo, section, msg) {
|
|
1923
|
+
const query = msg === undefined ? '' : `?msg=${encodeURIComponent(msg)}&in=${section}`;
|
|
1924
|
+
return `${urlOf(repo)}/settings${query}#${section}`;
|
|
1925
|
+
}
|
|
938
1926
|
async function loadForRepoAdmin(req, res, viewer) {
|
|
939
1927
|
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
940
1928
|
if (!loaded)
|
|
@@ -952,13 +1940,101 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
952
1940
|
const loaded = await loadForRepoAdmin(req, res, viewer);
|
|
953
1941
|
if (!loaded)
|
|
954
1942
|
return;
|
|
955
|
-
const backUrl = `${urlOf(loaded.repo)}/settings`;
|
|
956
1943
|
const priv = (0, web_1.field)(req, 'private') === 'true';
|
|
957
1944
|
(0, perms_1.setRepoPrivate)(loaded.repo.dir, priv);
|
|
958
1945
|
const msg = priv
|
|
959
1946
|
? 'This repository is now private: visible to its collaborators, the collection’s owners, and site admins.'
|
|
960
1947
|
: 'This repository is now public: anyone can read it.';
|
|
961
|
-
res.redirect(
|
|
1948
|
+
res.redirect(settingsBack(loaded.repo, 'access', msg));
|
|
1949
|
+
}));
|
|
1950
|
+
// The site's switch, source, hostname label, and custom domain, from the
|
|
1951
|
+
// Site box on the settings page. Admin, like visibility: enabling a site
|
|
1952
|
+
// publishes whatever the directory holds to everyone. Each field is applied
|
|
1953
|
+
// only when the form carried it, so the enable/disable button and the
|
|
1954
|
+
// config form can post to one route without clearing each other's fields.
|
|
1955
|
+
// The domain field is rendered only for a site admin and re-checked here,
|
|
1956
|
+
// since attaching a hostname is the operator's act.
|
|
1957
|
+
app.post('/:collection/:repo/settings/site', form, (0, web_1.ah)(async (req, res) => {
|
|
1958
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
1959
|
+
if (!viewer)
|
|
1960
|
+
return;
|
|
1961
|
+
const loaded = await loadForRepoAdmin(req, res, viewer);
|
|
1962
|
+
if (!loaded)
|
|
1963
|
+
return;
|
|
1964
|
+
const backUrl = settingsBack(loaded.repo, 'site');
|
|
1965
|
+
const body = req.body;
|
|
1966
|
+
const label = (0, web_1.field)(req, 'label').trim();
|
|
1967
|
+
if (body.label !== undefined && label !== '') {
|
|
1968
|
+
if ((0, siteshost_1.isReservedSiteLabel)(label)) {
|
|
1969
|
+
(0, web_1.fail)(res, 409, `The label ${label} is reserved for the operator of this vault: it is one of the names kept for the vault's own use under the sites host.`, viewer, backUrl);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (!(0, sitesettings_1.isUsableSiteLabel)(label)) {
|
|
1973
|
+
(0, web_1.fail)(res, 400, 'A site label is lowercase letters, digits, and single interior hyphens, at most 63 characters.', viewer, backUrl);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
const holder = (0, sitesettings_1.siteLabelConflict)(root, label, loaded.repo.collection, loaded.repo.name);
|
|
1977
|
+
if (holder) {
|
|
1978
|
+
(0, web_1.fail)(res, 409, `The label ${label} is already used by ${holder}.`, viewer, backUrl);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const source = (0, web_1.field)(req, 'source');
|
|
1983
|
+
if (body.source !== undefined && !(0, sitesettings_1.isSiteSource)(source)) {
|
|
1984
|
+
(0, web_1.fail)(res, 400, 'The site source must be copied files, workflow deploys, or repository contents.', viewer, backUrl);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const sitePath = body.path !== undefined ? (0, sitesettings_1.normalizeSitePath)((0, web_1.field)(req, 'path')) : null;
|
|
1988
|
+
if (sitePath !== null && !(0, sitesettings_1.isUsableSitePath)(sitePath)) {
|
|
1989
|
+
(0, web_1.fail)(res, 400, 'The directory must be a relative path within the repository, such as docs, or empty for the root.', viewer, backUrl);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (body.domain !== undefined) {
|
|
1993
|
+
if (!(0, perms_1.isSiteAdmin)(viewer.auth)) {
|
|
1994
|
+
(0, web_1.fail)(res, 403, 'Attaching a custom domain takes a site admin.', viewer, backUrl);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
const domain = (0, web_1.field)(req, 'domain').trim();
|
|
1998
|
+
if (domain === '') {
|
|
1999
|
+
(0, domains_1.clearRepoDomain)(root, loaded.repo.collection, loaded.repo.name);
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
const problem = (0, domains_1.setRepoDomain)(root, loaded.repo.collection, loaded.repo.name, domain);
|
|
2003
|
+
if (problem) {
|
|
2004
|
+
(0, web_1.fail)(res, problem.kind === 'conflict' ? 409 : 400, `The domain was refused: ${problem.message}.`, viewer, backUrl);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const changingSettings = body.enabled !== undefined || body.source !== undefined || body.label !== undefined || sitePath !== null;
|
|
2010
|
+
const settings = changingSettings
|
|
2011
|
+
? (0, sitesettings_1.editSiteSettings)(loaded.repo.dir, (s) => {
|
|
2012
|
+
if (body.enabled !== undefined)
|
|
2013
|
+
s.enabled = (0, web_1.field)(req, 'enabled') === 'true';
|
|
2014
|
+
if (body.source !== undefined && (0, sitesettings_1.isSiteSource)(source))
|
|
2015
|
+
s.source = source;
|
|
2016
|
+
if (body.label !== undefined)
|
|
2017
|
+
s.label = label;
|
|
2018
|
+
if (sitePath !== null)
|
|
2019
|
+
s.path = sitePath;
|
|
2020
|
+
})
|
|
2021
|
+
: (0, sitesettings_1.siteSettings)(loaded.repo.dir);
|
|
2022
|
+
let msg = body.enabled !== undefined
|
|
2023
|
+
? settings.enabled
|
|
2024
|
+
? 'The site is now enabled: the files in its site directory are served to everyone.'
|
|
2025
|
+
: 'The site is now disabled: its files stay on disk but nothing is served, and workflow deploys are refused.'
|
|
2026
|
+
: 'Site settings saved.';
|
|
2027
|
+
// A site published from the repository is written now, so that
|
|
2028
|
+
// enabling it or pointing it at a directory takes effect without
|
|
2029
|
+
// waiting for the next push; the outcome is part of the message.
|
|
2030
|
+
if (changingSettings) {
|
|
2031
|
+
const published = await (0, sitepublish_1.publishSiteForSettings)(root, loaded.repo);
|
|
2032
|
+
if (published && 'files' in published)
|
|
2033
|
+
msg += ` The site was published from the repository: ${published.files} file(s).`;
|
|
2034
|
+
else if (published)
|
|
2035
|
+
msg += ` The site could not be published from the repository: ${published.error}.`;
|
|
2036
|
+
}
|
|
2037
|
+
res.redirect(settingsBack(loaded.repo, 'site', msg));
|
|
962
2038
|
}));
|
|
963
2039
|
app.post('/:collection/:repo/settings/collaborators', form, (0, web_1.ah)(async (req, res) => {
|
|
964
2040
|
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
@@ -967,7 +2043,7 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
967
2043
|
const loaded = await loadForRepoAdmin(req, res, viewer);
|
|
968
2044
|
if (!loaded)
|
|
969
2045
|
return;
|
|
970
|
-
const backUrl =
|
|
2046
|
+
const backUrl = settingsBack(loaded.repo, 'access');
|
|
971
2047
|
const username = (0, web_1.field)(req, 'username').trim();
|
|
972
2048
|
const role = (0, web_1.field)(req, 'role');
|
|
973
2049
|
if (role !== 'read' && role !== 'write' && role !== 'admin') {
|
|
@@ -982,7 +2058,7 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
982
2058
|
return;
|
|
983
2059
|
}
|
|
984
2060
|
(0, perms_1.setCollaborator)(loaded.repo.dir, username, role);
|
|
985
|
-
res.redirect(
|
|
2061
|
+
res.redirect(settingsBack(loaded.repo, 'access', `${username} now has the ${role} role here.`));
|
|
986
2062
|
}));
|
|
987
2063
|
app.post('/:collection/:repo/settings/collaborators/remove', form, (0, web_1.ah)(async (req, res) => {
|
|
988
2064
|
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
@@ -991,14 +2067,14 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
991
2067
|
const loaded = await loadForRepoAdmin(req, res, viewer);
|
|
992
2068
|
if (!loaded)
|
|
993
2069
|
return;
|
|
994
|
-
const backUrl =
|
|
2070
|
+
const backUrl = settingsBack(loaded.repo, 'access');
|
|
995
2071
|
const username = (0, web_1.field)(req, 'username').trim();
|
|
996
2072
|
if ((0, perms_1.repoAccess)(loaded.repo.dir).collaborators[username] === undefined) {
|
|
997
2073
|
(0, web_1.fail)(res, 404, `${username} is not a collaborator here.`, viewer, backUrl);
|
|
998
2074
|
return;
|
|
999
2075
|
}
|
|
1000
2076
|
(0, perms_1.removeCollaborator)(loaded.repo.dir, username);
|
|
1001
|
-
res.redirect(
|
|
2077
|
+
res.redirect(settingsBack(loaded.repo, 'access', `Removed ${username}.`));
|
|
1002
2078
|
}));
|
|
1003
2079
|
// ---- forking ----
|
|
1004
2080
|
app.get('/:collection/:repo/fork', (0, web_1.ah)(async (req, res) => {
|
|
@@ -1049,6 +2125,24 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
1049
2125
|
}
|
|
1050
2126
|
res.redirect((0, views_1.repoUrl)({ collection: toCollection, repo: toName }));
|
|
1051
2127
|
}));
|
|
2128
|
+
// Syncing a fork from its upstream, like importing, runs on the operator's
|
|
2129
|
+
// machine: the server never fetches from another host. So the Sync link in
|
|
2130
|
+
// the repository header leads here, and this page only hands them the
|
|
2131
|
+
// command, the way the import page does.
|
|
2132
|
+
app.get('/:collection/:repo/sync', (0, web_1.ah)(async (req, res) => {
|
|
2133
|
+
const viewer = (0, web_1.requireViewerPage)(root, req, res);
|
|
2134
|
+
if (!viewer)
|
|
2135
|
+
return;
|
|
2136
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
2137
|
+
if (!loaded)
|
|
2138
|
+
return;
|
|
2139
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
2140
|
+
if (!ctx.upstream) {
|
|
2141
|
+
(0, web_1.fail)(res, 404, 'This repository has no upstream recorded, so there is nothing to sync from. Record one in its settings to make it a fork.', viewer, `${urlOf(loaded.repo)}/settings`);
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
res.type('html').send(forms.syncPage(ctx, `${req.protocol}://${req.get('host') ?? ''}`));
|
|
2145
|
+
}));
|
|
1052
2146
|
app.post('/:collection/:repo/settings/rename', form, (0, web_1.ah)(async (req, res) => {
|
|
1053
2147
|
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
1054
2148
|
if (!viewer)
|
|
@@ -1198,6 +2292,125 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
1198
2292
|
(0, themes_1.setActiveTheme)(name);
|
|
1199
2293
|
res.redirect(`/admin/appearance?msg=${encodeURIComponent(`Theme set to ${name}.`)}`);
|
|
1200
2294
|
});
|
|
2295
|
+
// ---- sign-in with GitHub, administered ----
|
|
2296
|
+
//
|
|
2297
|
+
// One page: the OAuth App's credentials, the approved list, and what is
|
|
2298
|
+
// already linked. All of it is vault-wide, so all of it takes a site admin,
|
|
2299
|
+
// like the theme and the egress budget.
|
|
2300
|
+
const githubAdminOnly = (viewer, res) => {
|
|
2301
|
+
if (canSetVaultWide(viewer))
|
|
2302
|
+
return true;
|
|
2303
|
+
(0, web_1.fail)(res, 403, 'Sign-in with GitHub is vault-wide, so it takes a site admin.', viewer, '/admin');
|
|
2304
|
+
return false;
|
|
2305
|
+
};
|
|
2306
|
+
app.get('/admin/github', (req, res) => {
|
|
2307
|
+
const viewer = requireAdminPage(req, res);
|
|
2308
|
+
if (!viewer)
|
|
2309
|
+
return;
|
|
2310
|
+
if (!githubAdminOnly(viewer, res))
|
|
2311
|
+
return;
|
|
2312
|
+
const state = (0, vault_1.loadVault)(root);
|
|
2313
|
+
if (state.status !== 'ok') {
|
|
2314
|
+
(0, web_1.fail)(res, 500, 'The vault is not available.', viewer, '/admin');
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
const approved = (state.vault.githubApproved ?? []).map((a) => ({
|
|
2318
|
+
...a,
|
|
2319
|
+
account: (0, vault_1.findGithubAccount)(state.vault, a.id)?.username ?? null,
|
|
2320
|
+
}));
|
|
2321
|
+
const linked = Object.entries(state.vault.users).flatMap(([username, u]) => u.github ? [{ username, id: u.github.id, login: u.github.login }] : []);
|
|
2322
|
+
const msg = typeof req.query.msg === 'string' ? req.query.msg : undefined;
|
|
2323
|
+
res.type('html').send(forms.adminGithubPage(viewer, {
|
|
2324
|
+
clientId: (0, config_1.loadConfig)(root).auth.githubClientId,
|
|
2325
|
+
secretSet: (0, githubauth_1.readGithubSecret)(root) !== null,
|
|
2326
|
+
callbackUrl: githubCallbackUrl(req),
|
|
2327
|
+
approved,
|
|
2328
|
+
linked,
|
|
2329
|
+
msg,
|
|
2330
|
+
}));
|
|
2331
|
+
});
|
|
2332
|
+
// The credentials post back to the page's own two-segment URL, like the
|
|
2333
|
+
// egress and appearance forms: a third segment named `settings` would be
|
|
2334
|
+
// shadowed by the /:collection/:repo/settings route registered above.
|
|
2335
|
+
app.post('/admin/github', form, (req, res) => {
|
|
2336
|
+
const viewer = requireAdminPost(req, res);
|
|
2337
|
+
if (!viewer)
|
|
2338
|
+
return;
|
|
2339
|
+
if (!githubAdminOnly(viewer, res))
|
|
2340
|
+
return;
|
|
2341
|
+
const clientId = (0, web_1.field)(req, 'clientId').trim();
|
|
2342
|
+
const clientSecret = (0, web_1.field)(req, 'clientSecret').trim();
|
|
2343
|
+
if (clientId === '') {
|
|
2344
|
+
(0, config_1.saveConfig)(root, { auth: { githubClientId: '' } });
|
|
2345
|
+
(0, githubauth_1.clearGithubSecret)(root);
|
|
2346
|
+
res.redirect(`/admin/github?msg=${encodeURIComponent('Sign-in with GitHub is off; the stored secret was removed.')}`);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (!/^[\x21-\x7e]{1,100}$/.test(clientId)) {
|
|
2350
|
+
(0, web_1.fail)(res, 400, 'That does not look like a client id.', viewer, '/admin/github');
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
if (clientSecret !== '' && !/^[\x21-\x7e]{1,200}$/.test(clientSecret)) {
|
|
2354
|
+
(0, web_1.fail)(res, 400, 'That does not look like a client secret.', viewer, '/admin/github');
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
(0, config_1.saveConfig)(root, { auth: { githubClientId: clientId } });
|
|
2358
|
+
if (clientSecret !== '')
|
|
2359
|
+
(0, githubauth_1.writeGithubSecret)(root, clientSecret);
|
|
2360
|
+
const ready = (0, githubauth_1.readGithubSecret)(root) !== null;
|
|
2361
|
+
res.redirect(`/admin/github?msg=${encodeURIComponent(ready ? 'Saved. Sign-in with GitHub is on.' : 'Client id saved. Add the client secret to turn sign-in on.')}`);
|
|
2362
|
+
});
|
|
2363
|
+
app.post('/admin/github/approve', form, (0, web_1.ah)(async (req, res) => {
|
|
2364
|
+
const viewer = requireAdminPost(req, res);
|
|
2365
|
+
if (!viewer)
|
|
2366
|
+
return;
|
|
2367
|
+
if (!githubAdminOnly(viewer, res))
|
|
2368
|
+
return;
|
|
2369
|
+
const login = (0, web_1.field)(req, 'login').trim().replace(/^@/, '');
|
|
2370
|
+
if (!(0, githubauth_1.isPlausibleGithubLogin)(login)) {
|
|
2371
|
+
(0, web_1.fail)(res, 400, 'That does not look like a GitHub username.', viewer, '/admin/github');
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
// Resolved to the numeric id now, over GitHub's public API, so the
|
|
2375
|
+
// approval survives any later rename of the login.
|
|
2376
|
+
const resolved = await (0, githubauth_1.lookupGithubLogin)(login);
|
|
2377
|
+
if (!resolved) {
|
|
2378
|
+
(0, web_1.fail)(res, 404, `GitHub does not know a user ${login}, or could not be reached; try again.`, viewer, '/admin/github');
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
(0, vault_1.approveGithub)(root, resolved);
|
|
2382
|
+
res.redirect(`/admin/github?msg=${encodeURIComponent(`Approved ${resolved.login} (GitHub id ${resolved.id}).`)}`);
|
|
2383
|
+
}));
|
|
2384
|
+
app.post('/admin/github/approved/:id/remove', form, (req, res) => {
|
|
2385
|
+
const viewer = requireAdminPost(req, res);
|
|
2386
|
+
if (!viewer)
|
|
2387
|
+
return;
|
|
2388
|
+
if (!githubAdminOnly(viewer, res))
|
|
2389
|
+
return;
|
|
2390
|
+
const id = Number(req.params.id);
|
|
2391
|
+
if (!Number.isInteger(id) || !(0, vault_1.unapproveGithub)(root, id)) {
|
|
2392
|
+
(0, web_1.fail)(res, 404, 'That GitHub account is not on the approved list.', viewer, '/admin/github');
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
res.redirect(`/admin/github?msg=${encodeURIComponent('Approval removed. Accounts already linked keep signing in until unlinked.')}`);
|
|
2396
|
+
});
|
|
2397
|
+
// The admin's off switch for a GitHub link, beside the ones for tokens and
|
|
2398
|
+
// passkeys: the user unlinks their own on /account, and this is for the day
|
|
2399
|
+
// they cannot.
|
|
2400
|
+
app.post('/admin/users/:name/github/unlink', form, (req, res) => {
|
|
2401
|
+
const viewer = requireAdminPost(req, res);
|
|
2402
|
+
if (!viewer)
|
|
2403
|
+
return;
|
|
2404
|
+
const backUrl = `/admin/users/${encodeURIComponent(req.params.name)}`;
|
|
2405
|
+
const found = loadUserForAdmin(req, res, viewer, backUrl);
|
|
2406
|
+
if (!found)
|
|
2407
|
+
return;
|
|
2408
|
+
if (!(0, vault_1.unlinkGithub)(root, found.name)) {
|
|
2409
|
+
(0, web_1.fail)(res, 404, `No GitHub account is linked to ${found.name}.`, viewer, backUrl);
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
res.redirect(`${backUrl}?msg=${encodeURIComponent('GitHub account unlinked.')}`);
|
|
2413
|
+
});
|
|
1201
2414
|
app.get('/admin/users', (req, res) => {
|
|
1202
2415
|
const viewer = requireAdminPage(req, res);
|
|
1203
2416
|
if (!viewer)
|
|
@@ -1230,7 +2443,7 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
1230
2443
|
(0, web_1.fail)(res, 409, `User ${username} already exists; use Grant or Mint token on the users page instead.`, viewer, backUrl);
|
|
1231
2444
|
return;
|
|
1232
2445
|
}
|
|
1233
|
-
const result = (0, vault_1.addUserToken)(root, username, { siteAdmin: (0, web_1.field)(req, 'siteAdmin') === 'true' });
|
|
2446
|
+
const result = (0, vault_1.addUserToken)(root, username, { siteAdmin: (0, web_1.field)(req, 'siteAdmin') === 'true', by: viewer.auth.username });
|
|
1234
2447
|
res.type('html').send(forms.tokenPage(viewer, username, result.token, true));
|
|
1235
2448
|
});
|
|
1236
2449
|
/**
|
|
@@ -1296,6 +2509,22 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
1296
2509
|
}
|
|
1297
2510
|
res.redirect(`${backUrl}?msg=${encodeURIComponent(`Revoked token ${req.params.id}.`)}`);
|
|
1298
2511
|
});
|
|
2512
|
+
// The admin's off switch for a passkey, beside the one for tokens: the
|
|
2513
|
+
// user removes their own on /account, and this is for the day they cannot.
|
|
2514
|
+
app.post('/admin/users/:name/passkeys/:id/delete', form, (req, res) => {
|
|
2515
|
+
const viewer = requireAdminPost(req, res);
|
|
2516
|
+
if (!viewer)
|
|
2517
|
+
return;
|
|
2518
|
+
const backUrl = `/admin/users/${encodeURIComponent(req.params.name)}`;
|
|
2519
|
+
const found = loadUserForAdmin(req, res, viewer, backUrl);
|
|
2520
|
+
if (!found)
|
|
2521
|
+
return;
|
|
2522
|
+
if (!(0, vault_1.removePasskey)(root, found.name, req.params.id)) {
|
|
2523
|
+
(0, web_1.fail)(res, 404, `No such passkey for ${found.name}.`, viewer, backUrl);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
res.redirect(`${backUrl}?msg=${encodeURIComponent('Passkey removed.')}`);
|
|
2527
|
+
});
|
|
1299
2528
|
app.post('/admin/users/:name/emails', form, (req, res) => {
|
|
1300
2529
|
const viewer = requireAdminPost(req, res);
|
|
1301
2530
|
if (!viewer)
|
|
@@ -1396,6 +2625,7 @@ function registerWebOps(app, root, authLimiter, lfs = null, engine, egress) {
|
|
|
1396
2625
|
}
|
|
1397
2626
|
const result = (0, vault_1.addUserToken)(root, username, {
|
|
1398
2627
|
tokenScope: tokenScope.length ? tokenScope : undefined,
|
|
2628
|
+
by: viewer.auth.username,
|
|
1399
2629
|
});
|
|
1400
2630
|
res.type('html').send(forms.tokenPage(viewer, username, result.token, false));
|
|
1401
2631
|
});
|