@magland/mochi 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +166 -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 +193 -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/session.js CHANGED
@@ -35,17 +35,19 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getSecret = getSecret;
37
37
  exports.setSessionCookie = setSessionCookie;
38
+ exports.renewSession = renewSession;
38
39
  exports.clearSessionCookie = clearSessionCookie;
39
40
  exports.getViewer = getViewer;
40
41
  exports.viewerIsAdmin = viewerIsAdmin;
41
42
  exports.checkCsrf = checkCsrf;
43
+ exports.originOk = originOk;
42
44
  exports.csrfMatches = csrfMatches;
43
45
  const crypto = __importStar(require("crypto"));
44
46
  const fs = __importStar(require("fs"));
45
47
  const path = __importStar(require("path"));
46
- const config_1 = require("./config");
48
+ const domains_1 = require("./domains");
47
49
  const filecache_1 = require("./filecache");
48
- const siteshost_1 = require("./siteshost");
50
+ const naming_1 = require("./naming");
49
51
  const perms_1 = require("./perms");
50
52
  const vault_1 = require("./vault");
51
53
  // Stateless signed-cookie sessions on top of the token model. The payload is
@@ -54,14 +56,15 @@ const vault_1 = require("./vault");
54
56
  // request, so deleting a user's tokens cuts them off, and rotating .secret
55
57
  // invalidates every session at once.
56
58
  //
57
- // A session is bound to the single token it was minted from, by recording that
58
- // token's SHA-256 hash (the same value vault.json stores, so no new secret is
59
- // put in the cookie, and the hash is useless as a credential). Resolving a
60
- // session looks that token up in live vault.json, so deleting one token ends
61
- // the sessions it started and leaves the user's other sessions alone. The
62
- // token record found this way is the real one, which is why the session carries
63
- // no copy of the token's scope: the role checks in src/perms.ts read it from
64
- // the vault.
59
+ // A session is bound to the single credential it was minted from, by
60
+ // recording that token's SHA-256 hash (the same value vault.json stores, so
61
+ // no new secret is put in the cookie, and the hash is useless as a
62
+ // credential) or, for a passkey sign-in, the passkey's binding string (see
63
+ // authForBinding in src/vault.ts). Resolving a session looks that credential
64
+ // up in live vault.json, so deleting one token or passkey ends the sessions
65
+ // it started and leaves the user's other sessions alone. The token record
66
+ // found this way is the real one, which is why the session carries no copy of
67
+ // the token's scope: the role checks in src/perms.ts read it from the vault.
65
68
  // Cookies are not scoped by origin, so any document on any host under a shared
66
69
  // parent domain can set a cookie named mochi_session with
67
70
  // Domain=<parent>, which the browser then also sends here. With several vaults
@@ -72,9 +75,14 @@ const vault_1 = require("./vault");
72
75
  // The prefix also requires Secure and Path=/, so the name is conditional on
73
76
  // exactly when the prefix is legal: setSessionCookie already sets path '/' and
74
77
  // sets secure from req.protocol, and a plain-http vault keeps the bare name.
75
- const HOST_COOKIE_NAME = '__Host-mochi_session';
76
- const COOKIE_NAME = 'mochi_session';
77
- const cookieName = (req) => (req.protocol === 'https' ? HOST_COOKIE_NAME : COOKIE_NAME);
78
+ // Read through src/naming.ts at call time, so an application that renamed the
79
+ // cookie (dango, say) gets its own name from the same modules; for mochiforge
80
+ // these spell mochi_session and __Host-mochi_session as they always did.
81
+ const hostCookieName = () => `__Host-${naming_1.naming.cookieName}`;
82
+ const cookieName = (req) => (req.protocol === 'https' ? hostCookieName() : naming_1.naming.cookieName);
83
+ // Effectively an idle limit rather than a hard one: renewSession below
84
+ // re-issues a cookie seen in the second half of its life, so only a session
85
+ // unused for this long actually expires.
78
86
  const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
79
87
  /** Write a fresh signing key where one is missing or unusable. */
80
88
  function mintSecret(file) {
@@ -110,23 +118,16 @@ function sign(root, data) {
110
118
  return b64url(crypto.createHmac('sha256', getSecret(root)).update(data).digest());
111
119
  }
112
120
  /**
113
- * Whether this request arrived on a hostname that serves sites. No session is
114
- * resolved and none is minted there. The sites middleware never asks for a
115
- * viewer, so this is defence in depth: it makes structurally true what would
116
- * otherwise be true only by inspection.
121
+ * Whether this request arrived on a hostname that serves sites, the sites host
122
+ * and every custom domain alike. No session is resolved and none is minted
123
+ * there. The sites middleware never asks for a viewer, so this is defence in
124
+ * depth: it makes structurally true what would otherwise be true only by
125
+ * inspection.
117
126
  */
118
127
  function onSitesHost(req, root) {
119
- return (0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, req.hostname);
128
+ return (0, domains_1.isSiteRequest)(root, req.hostname);
120
129
  }
121
- function setSessionCookie(req, res, root, auth) {
122
- if (onSitesHost(req, root))
123
- return;
124
- const payload = {
125
- u: auth.username,
126
- exp: Date.now() + SESSION_MS,
127
- csrf: crypto.randomBytes(16).toString('hex'),
128
- t: auth.token.hash,
129
- };
130
+ function writeSessionCookie(req, res, root, payload) {
130
131
  const body = b64url(Buffer.from(JSON.stringify(payload), 'utf8'));
131
132
  res.cookie(cookieName(req), `${body}.${sign(root, body)}`, {
132
133
  httpOnly: true,
@@ -136,11 +137,49 @@ function setSessionCookie(req, res, root, auth) {
136
137
  path: '/',
137
138
  });
138
139
  }
140
+ function setSessionCookie(req, res, root, auth) {
141
+ if (onSitesHost(req, root))
142
+ return;
143
+ writeSessionCookie(req, res, root, {
144
+ u: auth.username,
145
+ exp: Date.now() + SESSION_MS,
146
+ csrf: crypto.randomBytes(16).toString('hex'),
147
+ t: auth.token.hash,
148
+ });
149
+ }
150
+ /**
151
+ * A session in use is kept alive. A request arriving in the second half of the
152
+ * cookie's life gets a fresh cookie carrying the same identity, credential
153
+ * binding, and csrf value (so a form already open in another tab still
154
+ * submits), with the expiry pushed out to a full SESSION_MS again. An account
155
+ * visited more often than every SESSION_MS therefore never sees a sign-out,
156
+ * while a session left idle still dies on schedule. The renewal re-resolves
157
+ * the credential against live vault.json first: a session whose token or
158
+ * passkey has been revoked is not given a fresh cookie on its way out.
159
+ */
160
+ function renewSession(req, res, root) {
161
+ if (onSitesHost(req, root))
162
+ return;
163
+ const session = readSession(req, root);
164
+ if (!session)
165
+ return;
166
+ if (session.exp - Date.now() > SESSION_MS / 2)
167
+ return;
168
+ const state = (0, vault_1.loadVault)(root);
169
+ if (state.status !== 'ok')
170
+ return;
171
+ if (!(0, vault_1.authForBinding)(state.vault, session.u, session.t))
172
+ return;
173
+ writeSessionCookie(req, res, root, { ...session, exp: Date.now() + SESSION_MS });
174
+ }
139
175
  // Both names, since a session minted before the prefix existed, or over http
140
- // on a vault that has since gained TLS, is still a session to clear.
176
+ // on a vault that has since gained TLS, is still a session to clear. The
177
+ // prefix rules apply to the deletion Set-Cookie too: without Secure the
178
+ // browser discards it and the session survives, so sign-out would silently do
179
+ // nothing on an https vault.
141
180
  function clearSessionCookie(res) {
142
- res.clearCookie(HOST_COOKIE_NAME, { path: '/' });
143
- res.clearCookie(COOKIE_NAME, { path: '/' });
181
+ res.clearCookie(hostCookieName(), { path: '/', secure: true });
182
+ res.clearCookie(naming_1.naming.cookieName, { path: '/' });
144
183
  }
145
184
  function parseCookies(header) {
146
185
  const out = {};
@@ -160,11 +199,14 @@ function parseCookies(header) {
160
199
  return out;
161
200
  }
162
201
  function readSession(req, root) {
163
- // The prefixed name first, then the bare one, so a session minted before this
164
- // existed survives. A sibling subdomain can still set the bare name, which is
165
- // why the prefixed one wins when both arrive.
202
+ // Only the name this request would have been given a cookie under. On an
203
+ // https vault that is the prefixed one, and the bare name is not read at
204
+ // all: a sibling subdomain can set the bare name with a Domain attribute,
205
+ // and reading it here would let such a page hand a visitor a session of the
206
+ // attacker's. A session minted over https before the prefix existed is
207
+ // therefore a sign-out, once, which is the smaller cost.
166
208
  const cookies = parseCookies(req.headers.cookie);
167
- const raw = cookies[HOST_COOKIE_NAME] ?? cookies[COOKIE_NAME];
209
+ const raw = cookies[cookieName(req)];
168
210
  if (!raw)
169
211
  return null;
170
212
  const dot = raw.lastIndexOf('.');
@@ -208,13 +250,13 @@ function getViewer(req, root) {
208
250
  const state = (0, vault_1.loadVault)(root);
209
251
  if (state.status !== 'ok')
210
252
  return null;
211
- const user = state.vault.users[session.u];
212
- if (!user)
213
- return null;
214
- const token = user.tokens.find((t) => t.hash === session.t);
215
- if (!token)
253
+ // `t` is the token hash the session was minted from, or a passkey binding;
254
+ // either way it resolves against live vault.json, so revoking the token or
255
+ // removing the passkey ends the session on the next request.
256
+ const auth = (0, vault_1.authForBinding)(state.vault, session.u, session.t);
257
+ if (!auth)
216
258
  return null;
217
- return { auth: { username: session.u, user, token }, csrf: session.csrf };
259
+ return { auth, csrf: session.csrf };
218
260
  }
219
261
  function viewerIsAdmin(viewer) {
220
262
  return viewer !== null && (0, perms_1.isSiteAdmin)(viewer.auth);
package/dist/site.js CHANGED
@@ -36,14 +36,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.setSiteHeaders = setSiteHeaders;
37
37
  exports.serveSite = serveSite;
38
38
  exports.siteHostUrl = siteHostUrl;
39
+ exports.siteRedirectUrl = siteRedirectUrl;
39
40
  exports.registerSiteHost = registerSiteHost;
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const config_1 = require("./config");
44
+ const domains_1 = require("./domains");
43
45
  const ops_1 = require("./ops");
46
+ const perms_1 = require("./perms");
44
47
  const redirects_1 = require("./redirects");
45
48
  const render_1 = require("./render");
46
49
  const scan_1 = require("./scan");
50
+ const sitesettings_1 = require("./sitesettings");
47
51
  const siteshost_1 = require("./siteshost");
48
52
  const web_1 = require("./web");
49
53
  /**
@@ -96,15 +100,35 @@ function sendSite404(res, mode, message = 'Not found') {
96
100
  * wildcard, and on a site's own hostname the repository is the origin root, so
97
101
  * it is the whole of req.path.
98
102
  */
99
- function serveSite(root, collection, repo, req, res, mode) {
103
+ function serveSite(root, collection, repo, req, res, mode, auth = null) {
100
104
  const found = (0, scan_1.findRepo)(root, collection, repo);
105
+ const notFound = `Repository ${collection}/${repo} not found`;
101
106
  if (!found) {
102
- sendSite404(res, mode, `Repository ${collection}/${repo} not found`);
107
+ sendSite404(res, mode, notFound);
108
+ return;
109
+ }
110
+ // A private repository's published site is served to anyone, as GitHub
111
+ // Pages serves one (docs/vault.md says so and why). What is not given away
112
+ // is the repository's existence: a visitor who could not read it gets, for
113
+ // a site that is switched off or has no directory, the same sentence an
114
+ // absent repository gets, rather than one that names the repository and
115
+ // tells them how its admin could publish. On a sites hostname no session
116
+ // is resolved, so every visitor there is that visitor.
117
+ const hidden = (0, perms_1.repoIsPrivate)(found.dir) && !(0, perms_1.canReadRepo)(root, auth, found);
118
+ // The switch, checked before the directory: a site that is not enabled is
119
+ // not served whatever is on disk, and the files stay where they are for the
120
+ // day it is enabled again.
121
+ if (!(0, sitesettings_1.siteSettings)(found.dir).enabled) {
122
+ sendSite404(res, mode, hidden
123
+ ? notFound
124
+ : `${found.collection}/${found.name} does not publish a site. A repository admin can enable one in its settings.`);
103
125
  return;
104
126
  }
105
127
  const dir = (0, scan_1.siteDir)(root, found.collection, found.name);
106
128
  if (!dir) {
107
- sendSite404(res, mode, `No site for ${found.collection}/${found.name}. Create a ${found.name}.site directory next to the repository, with an index.html at its root.`);
129
+ sendSite404(res, mode, hidden
130
+ ? notFound
131
+ : `No site for ${found.collection}/${found.name}. Create a ${found.name}.site directory next to the repository, with an index.html at its root.`);
108
132
  return;
109
133
  }
110
134
  // The real path of the site directory, which every path we serve has to
@@ -193,14 +217,50 @@ function minimalPage(status, title, body) {
193
217
  </head><body><h1>${status} ${title}</h1><p>${body}</p></body></html>
194
218
  `;
195
219
  }
220
+ /**
221
+ * The one hostname a repository's site answers on, or null when it is served
222
+ * on the forge host. Precedence is custom domain, then custom label on the
223
+ * sites host, then the derived <repo>--<alias> label; every other hostname
224
+ * that reaches the same site 301s here, so published links converge on one
225
+ * origin the way they do for a rename.
226
+ */
227
+ function canonicalSiteHostname(root, collection, repo, repoDir) {
228
+ const domain = (0, domains_1.repoDomain)(root, collection, repo);
229
+ if (domain)
230
+ return domain;
231
+ const sitesHost = (0, config_1.loadConfig)(root).sites.host;
232
+ if (!sitesHost)
233
+ return null;
234
+ const label = (0, sitesettings_1.siteSettings)(repoDir).label;
235
+ if (label)
236
+ return `${label}.${sitesHost}`;
237
+ return (0, sitesettings_1.siteHostFor)(root, sitesHost, collection, repo);
238
+ }
196
239
  /**
197
240
  * The site's own URL for a repository, when it has an origin of its own, or null
198
241
  * when it is served on the forge host. The scheme mirrors the request, so a
199
- * local http vault redirects to http and stays usable.
242
+ * local http vault redirects to http and stays usable. This is where the site
243
+ * would be served, whether or not it is enabled; what gates links and
244
+ * redirects is the enabled flag, which the callers that need it check.
200
245
  */
201
246
  function siteHostUrl(root, req, collection, repo) {
202
- const sitesHost = (0, config_1.loadConfig)(root).sites.host;
203
- const host = (0, siteshost_1.siteHostFor)(sitesHost, collection, repo);
247
+ const found = (0, scan_1.findRepo)(root, collection, repo);
248
+ const host = found
249
+ ? canonicalSiteHostname(root, found.collection, found.name, found.dir)
250
+ : (0, sitesettings_1.siteHostFor)(root, (0, config_1.loadConfig)(root).sites.host, collection, repo);
251
+ return host ? `${req.protocol}://${host}` : null;
252
+ }
253
+ /**
254
+ * The origin the forge-path site routes should redirect to, or null to serve
255
+ * in place. Unlike siteHostUrl it answers only for a site that is enabled: a
256
+ * disabled site's forge path should 404 where it is asked rather than send the
257
+ * visitor to another origin to be told the same thing.
258
+ */
259
+ function siteRedirectUrl(root, req, collection, repo) {
260
+ const found = (0, scan_1.findRepo)(root, collection, repo);
261
+ if (!found || !(0, sitesettings_1.siteSettings)(found.dir).enabled)
262
+ return null;
263
+ const host = canonicalSiteHostname(root, found.collection, found.name, found.dir);
204
264
  return host ? `${req.protocol}://${host}` : null;
205
265
  }
206
266
  /**
@@ -217,40 +277,63 @@ function siteHostUrl(root, req, collection, repo) {
217
277
  */
218
278
  function registerSiteHost(app, root) {
219
279
  app.use((req, res, next) => {
280
+ // A custom domain is checked first because it is the one site hostname
281
+ // that is not under the sites host: one stat-cached read of domains.json,
282
+ // the same price the config read below already pays.
283
+ const viaDomain = (0, domains_1.domainRepoFor)(root, req.hostname);
220
284
  const sitesHost = (0, config_1.loadConfig)(root).sites.host;
221
- if (!sitesHost)
285
+ if (!viaDomain && !(sitesHost && (0, siteshost_1.isUnderSitesHost)(sitesHost, req.hostname)))
222
286
  return next();
223
- if (!(0, siteshost_1.isUnderSitesHost)(sitesHost, req.hostname))
224
- return next();
225
- // Sites are files. Nothing on this hostname writes anything, so nothing
287
+ // Sites are files. Nothing on these hostnames writes anything, so nothing
226
288
  // needs a method that would.
227
289
  if (req.method !== 'GET' && req.method !== 'HEAD') {
228
290
  res.status(405).set('Allow', 'GET, HEAD').type('html').send(minimalPage(405, 'Method not allowed', 'This hostname serves static files, so it answers only GET and HEAD.'));
229
291
  return;
230
292
  }
231
- const named = (0, siteshost_1.parseSiteHost)(sitesHost, req.hostname);
293
+ // What the hostname names: the mapped repository for a custom domain, and
294
+ // otherwise whatever the name under the sites host resolves to, which is
295
+ // findRepoBySiteHostname's business rather than this handler's.
296
+ const named = viaDomain ?? (0, sitesettings_1.findRepoBySiteHostname)(root, sitesHost, req.hostname);
232
297
  if (!named) {
233
- // A request to the bare sites host, or to a deeper name under it, gets a
234
- // minimal 404 rather than falling through to the forge, so the forge is
235
- // reachable only on its own hostname.
298
+ // A request to the bare sites host, to a deeper name under it, or to a
299
+ // label nothing answers to gets a minimal 404 rather than falling
300
+ // through to the forge, so the forge is reachable only on its own
301
+ // hostname.
236
302
  setSiteHeaders(res, 'sandbox');
237
303
  res.status(404).type('html').send(minimalPage(404, 'No site here', 'This hostname does not name a repository site in this vault.'));
238
304
  return;
239
305
  }
240
- // A site has a hostname of its own, built from the repository's name, so a
241
- // rename moves the site to a different hostname and every published link
242
- // to it stops resolving to anything. The redirect the forge host does for
243
- // paths is done here for hostnames, on the same terms: only when no
244
- // repository answers to the name in this one.
245
- if (!(0, scan_1.findRepo)(root, named.collection, named.repo)) {
306
+ const redirect = (host) => {
307
+ res
308
+ .status(301)
309
+ .set('Cache-Control', 'no-store')
310
+ .set('Location', `${req.protocol}://${host}${req.originalUrl}`)
311
+ .end();
312
+ };
313
+ const found = (0, scan_1.findRepo)(root, named.collection, named.repo);
314
+ if (!found) {
315
+ // A site has a hostname of its own, built from the repository's name, so
316
+ // a rename moves the site to a different hostname and every published
317
+ // link to it stops resolving to anything. The redirect the forge host
318
+ // does for paths is done here for hostnames, on the same terms: only
319
+ // when no repository answers to the name in this one.
246
320
  const moved = (0, redirects_1.resolveRepoRedirect)(root, named.collection, named.repo);
247
- const host = moved && (0, siteshost_1.siteHostFor)(sitesHost, moved.collection, moved.repo);
248
- if (host) {
249
- res
250
- .status(301)
251
- .set('Cache-Control', 'no-store')
252
- .set('Location', `${req.protocol}://${host}${req.originalUrl}`)
253
- .end();
321
+ const movedRepo = moved && (0, scan_1.findRepo)(root, moved.collection, moved.repo);
322
+ const host = movedRepo && canonicalSiteHostname(root, movedRepo.collection, movedRepo.name, movedRepo.dir);
323
+ if (host && host !== (0, siteshost_1.normalizeHostname)(req.hostname)) {
324
+ redirect(host);
325
+ return;
326
+ }
327
+ }
328
+ else {
329
+ // A repository with a custom domain or a custom label still answers on
330
+ // its other hostnames, by sending the visitor to the one it prefers, the
331
+ // way a renamed repository's old hostname does. no-store for the same
332
+ // reason: which hostname is canonical is a setting, and a cached 301
333
+ // would outlive changing it.
334
+ const canonical = canonicalSiteHostname(root, found.collection, found.name, found.dir);
335
+ if (canonical && canonical !== (0, siteshost_1.normalizeHostname)(req.hostname)) {
336
+ redirect(canonical);
254
337
  return;
255
338
  }
256
339
  }
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SitePublishError = void 0;
37
+ exports.describeSiteSource = describeSiteSource;
38
+ exports.siteFollowsRef = siteFollowsRef;
39
+ exports.publishSiteFromRepository = publishSiteFromRepository;
40
+ exports.publishSiteAfterPush = publishSiteAfterPush;
41
+ exports.publishSiteForSettings = publishSiteForSettings;
42
+ exports.siteDirExists = siteDirExists;
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ const artifacts_1 = require("./ci/artifacts");
46
+ const git_1 = require("./git");
47
+ const layout_1 = require("./layout");
48
+ const scan_1 = require("./scan");
49
+ const sitesettings_1 = require("./sitesettings");
50
+ // Publishing a site from the repository itself: the third site source, beside
51
+ // files copied into the directory and a workflow's deploy step. With it, the
52
+ // site directory is a checkout the server maintains of the default branch (or
53
+ // a directory within it), rewritten on every push that moves that branch and
54
+ // whenever the settings that decide it change. It is what GitHub Pages calls
55
+ // deploying from a branch, and it is the mode for a repository whose site is
56
+ // plain files that need no build: the push is the deploy, and nothing else has
57
+ // to run anywhere.
58
+ //
59
+ // The tree is taken with `git archive`, so a `.gitattributes` line such as
60
+ // `secret.txt export-ignore` keeps a file out of the site, and it goes through
61
+ // the same extraction as a workflow artifact does (installSiteFromTar): into a
62
+ // scratch directory, symlinks that would escape it removed, then one rename.
63
+ // A failed publish leaves the previous site in place.
64
+ class SitePublishError extends Error {
65
+ }
66
+ exports.SitePublishError = SitePublishError;
67
+ /** How a site source publishes, for a message of the form "published by ...". */
68
+ function describeSiteSource(source) {
69
+ switch (source) {
70
+ case 'actions':
71
+ return 'workflow deploys';
72
+ case 'repository':
73
+ return 'the repository itself, from its default branch';
74
+ default:
75
+ return 'copying files';
76
+ }
77
+ }
78
+ /**
79
+ * Whether a push that moved `ref` should republish this repository's site:
80
+ * only a 'repository' site that is enabled, and only for the default branch.
81
+ */
82
+ async function siteFollowsRef(repo, ref, settings = (0, sitesettings_1.siteSettings)(repo.dir)) {
83
+ if (!settings.enabled || settings.source !== 'repository')
84
+ return false;
85
+ const branch = await repo.defaultBranch(await repo.listRefs('heads'));
86
+ return branch !== null && ref === `refs/heads/${branch}`;
87
+ }
88
+ /**
89
+ * Write the site directory from the default branch's tree, or from `path`
90
+ * within it. Returns the number of files published, or null when the settings
91
+ * do not ask for it (not enabled, or another source): a caller may call this
92
+ * unconditionally after anything that might have changed what the site should
93
+ * hold. Throws SitePublishError when the settings ask for it and it cannot be
94
+ * done: no default branch yet, a directory the branch does not have, or a tree
95
+ * with no files in it.
96
+ */
97
+ async function publishSiteFromRepository(root, repo) {
98
+ const settings = (0, sitesettings_1.siteSettings)(repo.dir);
99
+ if (!settings.enabled || settings.source !== 'repository')
100
+ return null;
101
+ const branch = await repo.defaultBranch(await repo.listRefs('heads'));
102
+ if (!branch)
103
+ throw new SitePublishError('the repository has no default branch to publish the site from');
104
+ const spec = settings.path ? `${branch}:${settings.path}` : branch;
105
+ const what = settings.path ? `the directory ${settings.path} on ${branch}` : `the branch ${branch}`;
106
+ const tar = `${(0, layout_1.repoPath)(root, repo.collection, `${(0, scan_1.displayName)(repo.name)}.site`)}.archive-${process.pid}.tar`;
107
+ fs.rmSync(tar, { force: true });
108
+ try {
109
+ try {
110
+ // `--` is not accepted by git archive between the tree-ish and the
111
+ // paths, so the spec is kept from looking like an option by the branch
112
+ // name rules (no leading dash) and by isUsableSitePath.
113
+ await (0, git_1.execGit)(repo.dir, ['archive', '--format=tar', '-o', tar, spec]);
114
+ }
115
+ catch (e) {
116
+ const msg = e instanceof Error ? e.message : String(e);
117
+ if (/not a tree object|not a valid object name|Not a valid object name|no such ref|did not match any files/i.test(msg)) {
118
+ throw new SitePublishError(`${what} does not exist, so there is nothing to publish`);
119
+ }
120
+ throw new SitePublishError(`git archive failed for ${what}: ${msg}`);
121
+ }
122
+ try {
123
+ return await (0, artifacts_1.installSiteFromTar)(root, repo.collection, repo.name, tar, { unwrapPagesArtifact: false, what });
124
+ }
125
+ catch (e) {
126
+ if (e instanceof artifacts_1.ArtifactError)
127
+ throw new SitePublishError(e.message);
128
+ throw e;
129
+ }
130
+ }
131
+ finally {
132
+ fs.rmSync(tar, { force: true });
133
+ }
134
+ }
135
+ /**
136
+ * The publish that follows a push: republish when the moved ref is the one the
137
+ * site follows, and otherwise do nothing. Errors are logged rather than
138
+ * thrown, since by the time this runs the push has been accepted and the
139
+ * pusher has gone; the settings page and the API report the same failure
140
+ * when the site is next saved, which is where a person can act on it.
141
+ */
142
+ async function publishSiteAfterPush(root, repo, ref) {
143
+ try {
144
+ if (!(await siteFollowsRef(repo, ref)))
145
+ return;
146
+ const result = await publishSiteFromRepository(root, repo);
147
+ if (result)
148
+ console.log(`site for ${repo.collection}/${repo.name} published from ${ref}: ${result.files} file(s)`);
149
+ }
150
+ catch (e) {
151
+ console.error(`site publish for ${repo.collection}/${repo.name} failed: ${e instanceof Error ? e.message : e}`);
152
+ }
153
+ }
154
+ /**
155
+ * The result of a publish attempted because settings changed, in the shape
156
+ * the settings page and the PATCH route hand back: what was published, or
157
+ * why it was not.
158
+ */
159
+ async function publishSiteForSettings(root, repo) {
160
+ try {
161
+ return await publishSiteFromRepository(root, repo);
162
+ }
163
+ catch (e) {
164
+ if (e instanceof SitePublishError)
165
+ return { error: e.message };
166
+ throw e;
167
+ }
168
+ }
169
+ /** Whether the site directory exists, for messages that say what happened. */
170
+ function siteDirExists(root, collection, repo) {
171
+ try {
172
+ return fs.statSync(path.join((0, layout_1.repoPath)(root, collection, `${(0, scan_1.displayName)(repo)}.site`))).isDirectory();
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ }