@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.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +138 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +192 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. 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.status(500).type('html').send(forms.loginPage(next, 'The vault is not available; try again later.'));
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
- app.post('/logout', form, (req, res) => {
179
- const viewer = (0, session_1.getViewer)(req, root);
180
- if (viewer && (0, session_1.checkCsrf)(req, viewer))
181
- (0, session_1.clearSessionCookie)(res);
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 type = await loaded.repo.entryType(branch, filePath);
503
- if (type !== 'blob') {
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
- const wantedPath = (0, web_1.field)(req, 'path').trim().replace(/^\/+/, '');
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
- const retryUrl = `${urlOf(loaded.repo)}/edit/${(0, views_1.encPath)(branch)}/${(0, views_1.encPath)(filePath)}`;
540
- if (toPath !== undefined && !(0, git_1.isValidRepoPath)(toPath)) {
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 name = (upload.filename ?? '').split(/[\\/]/).pop() ?? '';
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.isValidRepoPath)(full)) {
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
- res.redirect(`${urlOf(loaded.repo)}/tree/${(0, views_1.encPath)(onto)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`);
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
- const filename = (0, web_1.field)(req, 'filename').trim().replace(/^\/+|\/+$/g, '');
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.isValidRepoPath)(fullPath)) {
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
- res.type('html').send(forms.settingsPage(ctx, (0, scan_1.repoDescription)(loaded.repo.dir) ?? '', {
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
- }, msg));
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 = `${urlOf(loaded.repo)}/settings`;
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
- ops.setDescription(loaded.repo.dir, (0, web_1.field)(req, 'description'));
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(`${backUrl}?msg=${encodeURIComponent('Settings saved.')}`);
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(`${backUrl}?msg=${encodeURIComponent(msg)}`);
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 = `${urlOf(loaded.repo)}/settings`;
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(`${backUrl}?msg=${encodeURIComponent(`${username} now has the ${role} role here.`)}`);
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 = `${urlOf(loaded.repo)}/settings`;
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(`${backUrl}?msg=${encodeURIComponent(`Removed ${username}.`)}`);
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
  });