@magland/mochi 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +138 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +192 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/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
|
|
48
|
+
const domains_1 = require("./domains");
|
|
47
49
|
const filecache_1 = require("./filecache");
|
|
48
|
-
const
|
|
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
|
|
58
|
-
// token's SHA-256 hash (the same value vault.json stores, so
|
|
59
|
-
// put in the cookie, and the hash is useless as a
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// the
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
114
|
-
*
|
|
115
|
-
* viewer, so this is defence in
|
|
116
|
-
* otherwise be true only by
|
|
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,
|
|
128
|
+
return (0, domains_1.isSiteRequest)(root, req.hostname);
|
|
120
129
|
}
|
|
121
|
-
function
|
|
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(
|
|
143
|
-
res.clearCookie(
|
|
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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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[
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
if (!
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
203
|
-
const host =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
234
|
-
//
|
|
235
|
-
// reachable only on its own
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|