@magland/mochi 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
@@ -0,0 +1,267 @@
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.getSecret = getSecret;
37
+ exports.setSessionCookie = setSessionCookie;
38
+ exports.clearSessionCookie = clearSessionCookie;
39
+ exports.getViewer = getViewer;
40
+ exports.viewerIsAdmin = viewerIsAdmin;
41
+ exports.checkCsrf = checkCsrf;
42
+ exports.csrfMatches = csrfMatches;
43
+ const crypto = __importStar(require("crypto"));
44
+ const fs = __importStar(require("fs"));
45
+ const path = __importStar(require("path"));
46
+ const config_1 = require("./config");
47
+ const filecache_1 = require("./filecache");
48
+ const siteshost_1 = require("./siteshost");
49
+ const perms_1 = require("./perms");
50
+ const vault_1 = require("./vault");
51
+ // Stateless signed-cookie sessions on top of the token model. The payload is
52
+ // base64url JSON plus an HMAC keyed by <vault>/.secret. There is no server-side
53
+ // session store: permissions are re-derived from live vault.json on every
54
+ // request, so deleting a user's tokens cuts them off, and rotating .secret
55
+ // invalidates every session at once.
56
+ //
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.
65
+ // Cookies are not scoped by origin, so any document on any host under a shared
66
+ // parent domain can set a cookie named mochi_session with
67
+ // Domain=<parent>, which the browser then also sends here. With several vaults
68
+ // on sibling subdomains of one domain, that lets one of them shadow another's
69
+ // session. Browsers refuse a __Host--prefixed cookie that carries a Domain
70
+ // attribute at all, which closes it.
71
+ //
72
+ // The prefix also requires Secure and Path=/, so the name is conditional on
73
+ // exactly when the prefix is legal: setSessionCookie already sets path '/' and
74
+ // 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
+ const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
79
+ /** Write a fresh signing key where one is missing or unusable. */
80
+ function mintSecret(file) {
81
+ const secret = crypto.randomBytes(32);
82
+ fs.writeFileSync(file, secret, { mode: 0o600 });
83
+ return secret;
84
+ }
85
+ // Stat-cached rather than held forever, so that rotating the file does what
86
+ // the documentation says it does -- ends every session at once -- on the
87
+ // running server, not on the next restart. The stat per check is the same
88
+ // price every other state file pays.
89
+ const secretCache = (0, filecache_1.fileCache)({
90
+ read: (file) => {
91
+ try {
92
+ const secret = fs.readFileSync(file);
93
+ if (secret.length >= 32)
94
+ return secret;
95
+ }
96
+ catch {
97
+ // unreadable; replaced below
98
+ }
99
+ return mintSecret(file);
100
+ },
101
+ missing: (file) => mintSecret(file),
102
+ });
103
+ function getSecret(root) {
104
+ return secretCache.get(path.join(root, '.secret'));
105
+ }
106
+ function b64url(buf) {
107
+ return buf.toString('base64url');
108
+ }
109
+ function sign(root, data) {
110
+ return b64url(crypto.createHmac('sha256', getSecret(root)).update(data).digest());
111
+ }
112
+ /**
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.
117
+ */
118
+ function onSitesHost(req, root) {
119
+ return (0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, req.hostname);
120
+ }
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
+ const body = b64url(Buffer.from(JSON.stringify(payload), 'utf8'));
131
+ res.cookie(cookieName(req), `${body}.${sign(root, body)}`, {
132
+ httpOnly: true,
133
+ sameSite: 'lax',
134
+ secure: req.protocol === 'https',
135
+ maxAge: SESSION_MS,
136
+ path: '/',
137
+ });
138
+ }
139
+ // 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.
141
+ function clearSessionCookie(res) {
142
+ res.clearCookie(HOST_COOKIE_NAME, { path: '/' });
143
+ res.clearCookie(COOKIE_NAME, { path: '/' });
144
+ }
145
+ function parseCookies(header) {
146
+ const out = {};
147
+ if (!header)
148
+ return out;
149
+ for (const part of header.split(';')) {
150
+ const i = part.indexOf('=');
151
+ if (i === -1)
152
+ continue;
153
+ try {
154
+ out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
155
+ }
156
+ catch {
157
+ // malformed cookie value; skip
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ 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.
166
+ const cookies = parseCookies(req.headers.cookie);
167
+ const raw = cookies[HOST_COOKIE_NAME] ?? cookies[COOKIE_NAME];
168
+ if (!raw)
169
+ return null;
170
+ const dot = raw.lastIndexOf('.');
171
+ if (dot === -1)
172
+ return null;
173
+ const body = raw.slice(0, dot);
174
+ const sig = raw.slice(dot + 1);
175
+ const expected = sign(root, body);
176
+ const a = Buffer.from(sig);
177
+ const b = Buffer.from(expected);
178
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
179
+ return null;
180
+ let payload;
181
+ try {
182
+ payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ if (typeof payload !== 'object' || payload === null)
188
+ return null;
189
+ const p = payload;
190
+ if (typeof p.u !== 'string' || typeof p.exp !== 'number' || typeof p.csrf !== 'string')
191
+ return null;
192
+ if (p.exp < Date.now())
193
+ return null;
194
+ // A cookie from before sessions were bound to a token has no `t`, and is
195
+ // rejected rather than accepted unbound: the holder is simply signed out and
196
+ // signs in again, which is a smaller cost than leaving unrevocable sessions
197
+ // alive for the rest of their thirty days.
198
+ if (typeof p.t !== 'string' || p.t === '')
199
+ return null;
200
+ return { u: p.u, exp: p.exp, csrf: p.csrf, t: p.t };
201
+ }
202
+ function getViewer(req, root) {
203
+ if (onSitesHost(req, root))
204
+ return null;
205
+ const session = readSession(req, root);
206
+ if (!session)
207
+ return null;
208
+ const state = (0, vault_1.loadVault)(root);
209
+ if (state.status !== 'ok')
210
+ 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)
216
+ return null;
217
+ return { auth: { username: session.u, user, token }, csrf: session.csrf };
218
+ }
219
+ function viewerIsAdmin(viewer) {
220
+ return viewer !== null && (0, perms_1.isSiteAdmin)(viewer.auth);
221
+ }
222
+ function checkCsrf(req, viewer) {
223
+ const presented = req.body?.csrf;
224
+ return typeof presented === 'string' && csrfMatches(req, presented, viewer);
225
+ }
226
+ /**
227
+ * A state-changing request whose Origin names somewhere else is refused. This
228
+ * does nothing against same-origin script, which is what the site sandbox in
229
+ * src/site.ts closes; it is a few lines that catch a misconfigured proxy, and it
230
+ * belongs next to the CSRF check so the two are read together. An absent Origin
231
+ * is not a failure: plenty of legitimate clients send none.
232
+ */
233
+ function originOk(req) {
234
+ const origin = req.get('origin');
235
+ // Absent is allowed. Literal "null" is not: that is an opaque origin, which
236
+ // is what a sandboxed document has, and nothing in the forge's own interface
237
+ // sends it.
238
+ if (!origin)
239
+ return true;
240
+ if (origin === 'null')
241
+ return false;
242
+ let u;
243
+ try {
244
+ u = new URL(origin);
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ // The hostname only, and not the scheme or the port. req.hostname honours
250
+ // X-Forwarded-Host, so it is the name the browser used; req.protocol is
251
+ // http on a vault behind a TLS proxy that is not trusted, and comparing it
252
+ // would then refuse every legitimate form on a merely misconfigured vault.
253
+ // An attacker who can answer for our hostname over plain http is already
254
+ // between the browser and the server.
255
+ return u.hostname.toLowerCase() === req.hostname.toLowerCase();
256
+ }
257
+ /**
258
+ * The comparison behind checkCsrf, for a handler that has the value in hand
259
+ * rather than in req.body - a multipart form, which express does not parse.
260
+ */
261
+ function csrfMatches(req, presented, viewer) {
262
+ if (!originOk(req))
263
+ return false;
264
+ const a = Buffer.from(presented);
265
+ const b = Buffer.from(viewer.csrf);
266
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
267
+ }
package/dist/site.js ADDED
@@ -0,0 +1,259 @@
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.setSiteHeaders = setSiteHeaders;
37
+ exports.serveSite = serveSite;
38
+ exports.siteHostUrl = siteHostUrl;
39
+ exports.registerSiteHost = registerSiteHost;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const config_1 = require("./config");
43
+ const ops_1 = require("./ops");
44
+ const redirects_1 = require("./redirects");
45
+ const render_1 = require("./render");
46
+ const scan_1 = require("./scan");
47
+ const siteshost_1 = require("./siteshost");
48
+ const web_1 = require("./web");
49
+ /**
50
+ * The headers a site response carries. Set immediately before the body is sent,
51
+ * on every path that sends one, the site's own 404.html included: a response
52
+ * that escapes this is a response running with the forge's authority.
53
+ */
54
+ function setSiteHeaders(res, mode) {
55
+ // nosniff in both modes. A file whose declared type is wrong is not licence
56
+ // to guess something more dangerous.
57
+ res.setHeader('X-Content-Type-Options', 'nosniff');
58
+ if (mode === 'host')
59
+ return;
60
+ // Deliberately without allow-same-origin, which is what puts the document in
61
+ // an opaque origin: no cookie jar, no document.cookie, no localStorage or
62
+ // IndexedDB, no service worker registration, and every request it makes to
63
+ // the vault is cross-origin and credential-less. Script still runs, so the
64
+ // feature survives.
65
+ res.setHeader('Content-Security-Policy', 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads');
66
+ // Load-bearing, not redundant. A page in an opaque origin cannot fetch its
67
+ // own sibling files without it, which breaks any single-page app, wasm
68
+ // loader, or data-driven site. Those fetches still carry no credentials, so
69
+ // nothing is returned to an attacker. Site responses only: never on a forge
70
+ // page and never on an API route.
71
+ res.setHeader('Access-Control-Allow-Origin', '*');
72
+ }
73
+ /**
74
+ * How a site request refuses.
75
+ *
76
+ * On a site's own hostname the forge's error page is the wrong answer for the
77
+ * same reason it is wrong for the sites host's other responses: it renders forge
78
+ * chrome and links a stylesheet this origin does not serve, so it arrives
79
+ * unstyled and points the visitor at a vault the site knows nothing about. A
80
+ * repository with no site directory at all is the ordinary way to reach this,
81
+ * so it is worth answering plainly.
82
+ */
83
+ function sendSite404(res, mode, message = 'Not found') {
84
+ if (mode !== 'host') {
85
+ (0, web_1.send404)(res, message);
86
+ return;
87
+ }
88
+ setSiteHeaders(res, mode);
89
+ res.status(404).type('html').send(minimalPage(404, 'Not found', (0, render_1.esc)(message)));
90
+ }
91
+ /**
92
+ * Serve one path out of a repository's site directory.
93
+ *
94
+ * The site-relative path depends on the mode, because the two surfaces disagree
95
+ * about where the site starts: under /:collection/:repo/site/ it is the
96
+ * wildcard, and on a site's own hostname the repository is the origin root, so
97
+ * it is the whole of req.path.
98
+ */
99
+ function serveSite(root, collection, repo, req, res, mode) {
100
+ const found = (0, scan_1.findRepo)(root, collection, repo);
101
+ if (!found) {
102
+ sendSite404(res, mode, `Repository ${collection}/${repo} not found`);
103
+ return;
104
+ }
105
+ const dir = (0, scan_1.siteDir)(root, found.collection, found.name);
106
+ 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.`);
108
+ return;
109
+ }
110
+ // The real path of the site directory, which every path we serve has to
111
+ // sit under. It is resolved once here so that a vault reached through a
112
+ // symlinked root still compares equal.
113
+ let dirReal;
114
+ try {
115
+ dirReal = fs.realpathSync(dir);
116
+ }
117
+ catch {
118
+ sendSite404(res, mode);
119
+ return;
120
+ }
121
+ const relative = mode === 'host' ? req.path : (0, web_1.wildcard)(req);
122
+ const segs = relative.split('/').filter((s) => s !== '' && s !== '.');
123
+ if (segs.some((s) => s === '..' || s.includes('\0'))) {
124
+ sendSite404(res, mode);
125
+ return;
126
+ }
127
+ // Site content is written by whatever published it, which may be a
128
+ // workflow unpacking a tar, so it can contain symlinks. Refusing `..`
129
+ // segments stops a traversal spelled in the URL but not a link on disk
130
+ // pointing at /etc/passwd, at the vault's own files, or into another
131
+ // repository's site, which has a different owner. Every path we are
132
+ // about to read is therefore resolved and required to land strictly
133
+ // under dirReal; containedIn refuses the directory itself as well as
134
+ // anything outside it, so only files below the site root are servable.
135
+ //
136
+ // Resolving and then opening is two syscalls, so a link swapped in
137
+ // between them would still be followed. Closing that window means
138
+ // holding an open handle and checking what we hold, which is more
139
+ // machinery than this is worth; the interesting case is content that
140
+ // was already on disk when the request arrived.
141
+ const statInside = (p) => {
142
+ if (!(0, ops_1.containedIn)(dirReal, p))
143
+ return null;
144
+ try {
145
+ return fs.statSync(p);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ };
151
+ const requested = path.join(dir, ...segs);
152
+ // The site root is the directory itself, which statInside refuses; it is
153
+ // still a directory request, and resolves to the index.html below it.
154
+ const isDir = segs.length === 0 || statInside(requested)?.isDirectory() === true;
155
+ let target = requested;
156
+ if (isDir) {
157
+ if (!req.path.endsWith('/')) {
158
+ res.redirect(`${req.path}/`);
159
+ return;
160
+ }
161
+ target = path.join(requested, 'index.html');
162
+ }
163
+ // A refused path answers exactly as a missing one does, so a prober
164
+ // learns nothing about what is really there from the difference.
165
+ const stat = statInside(target);
166
+ if (!stat || !stat.isFile()) {
167
+ const notFound = path.join(dir, '404.html');
168
+ const notFoundStat = statInside(notFound);
169
+ if (notFoundStat && notFoundStat.isFile()) {
170
+ setSiteHeaders(res, mode);
171
+ res.status(404).sendFile(notFound);
172
+ }
173
+ else {
174
+ sendSite404(res, mode, 'Page not found in this site');
175
+ }
176
+ return;
177
+ }
178
+ setSiteHeaders(res, mode);
179
+ res.sendFile(target);
180
+ }
181
+ // ---- serving a site from its own hostname ----
182
+ /**
183
+ * A self-contained page, for the responses a sites hostname gives that are not
184
+ * site content. views.errorPage is wrong here: it renders forge chrome and links
185
+ * a stylesheet that is not reachable on this hostname, so it would arrive
186
+ * unstyled and mention a vault this origin knows nothing about.
187
+ */
188
+ function minimalPage(status, title, body) {
189
+ return `<!doctype html>
190
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
191
+ <title>${status} ${title}</title>
192
+ <style>body{font:16px/1.5 system-ui,sans-serif;margin:4rem auto;max-width:34rem;padding:0 1rem}</style>
193
+ </head><body><h1>${status} ${title}</h1><p>${body}</p></body></html>
194
+ `;
195
+ }
196
+ /**
197
+ * The site's own URL for a repository, when it has an origin of its own, or null
198
+ * 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.
200
+ */
201
+ 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);
204
+ return host ? `${req.protocol}://${host}` : null;
205
+ }
206
+ /**
207
+ * The site handler for requests that arrive on a sites hostname. It must run
208
+ * before every other route, the /assets/* and /favicon.svg routes included, or
209
+ * the forge's stylesheet would shadow a site's own assets/style.css and the
210
+ * favicon route would shadow a site's icon.
211
+ *
212
+ * Host-based gating is safe in this direction. A request arriving with a forged
213
+ * Host reaches only the less privileged surface, and forging the forge's own
214
+ * hostname gains an attacker nothing they would not get by visiting it, because
215
+ * the session is resolved only there and only from a cookie the browser sends to
216
+ * the real host.
217
+ */
218
+ function registerSiteHost(app, root) {
219
+ app.use((req, res, next) => {
220
+ const sitesHost = (0, config_1.loadConfig)(root).sites.host;
221
+ if (!sitesHost)
222
+ 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
226
+ // needs a method that would.
227
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
228
+ 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
+ return;
230
+ }
231
+ const named = (0, siteshost_1.parseSiteHost)(sitesHost, req.hostname);
232
+ 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.
236
+ setSiteHeaders(res, 'sandbox');
237
+ res.status(404).type('html').send(minimalPage(404, 'No site here', 'This hostname does not name a repository site in this vault.'));
238
+ return;
239
+ }
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)) {
246
+ 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();
254
+ return;
255
+ }
256
+ }
257
+ serveSite(root, named.collection, named.repo, req, res, 'host');
258
+ });
259
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSiteLabelSafe = isSiteLabelSafe;
4
+ exports.normalizeHostname = normalizeHostname;
5
+ exports.siteHostFor = siteHostFor;
6
+ exports.parseSiteHost = parseSiteHost;
7
+ exports.isUnderSitesHost = isUnderSitesHost;
8
+ const scan_1 = require("./scan");
9
+ // The grammar of a per-site hostname, in both directions, so that the parser
10
+ // and the link builder cannot drift apart. A site served from
11
+ // <repo>--<collection>.<sitesHost> has an origin of its own, which is what gives
12
+ // it back the cookies, storage, and service workers that the sandbox on the
13
+ // forge host takes away.
14
+ /**
15
+ * Whether a collection or repository name may appear in a site hostname. The
16
+ * regex is the whole definition: lowercase letters, digits, and single interior
17
+ * hyphens, which by construction rejects uppercase, '.', '_', a leading or
18
+ * trailing hyphen, a doubled hyphen anywhere, and the empty string.
19
+ */
20
+ function isSiteLabelSafe(name) {
21
+ return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
22
+ }
23
+ /** The DNS limit on one label, which <repo>--<collection> has to fit inside. */
24
+ const MAX_LABEL = 63;
25
+ /**
26
+ * The one normalization a hostname gets, wherever one is read. Exported because
27
+ * the configured sites host and the request's own hostname are compared against
28
+ * each other: if the two were normalized differently, a trailing dot or a
29
+ * capital letter on one side would stop every site being found.
30
+ */
31
+ function normalizeHostname(hostname) {
32
+ return hostname.trim().toLowerCase().replace(/\.$/, '');
33
+ }
34
+ /**
35
+ * The full hostname for a repository's site, or null if it is not eligible.
36
+ *
37
+ * Repository and collection names are more permissive than a hostname label
38
+ * (isValidName allows '.', '_', and uppercase), so not every repository is
39
+ * eligible, and an ineligible one keeps being served on the forge host. Refusing
40
+ * rather than mangling is deliberate: lowercasing `Webapp1` would collide with a
41
+ * `webapp1` beside it, since hostnames are case-insensitive and both names are
42
+ * legal on disk.
43
+ */
44
+ function siteHostFor(sitesHost, collection, repo) {
45
+ if (!sitesHost)
46
+ return null;
47
+ const name = (0, scan_1.displayName)(repo);
48
+ if (!isSiteLabelSafe(name) || !isSiteLabelSafe(collection))
49
+ return null;
50
+ const label = `${name}--${collection}`;
51
+ if (label.length > MAX_LABEL)
52
+ return null;
53
+ return `${label}.${sitesHost}`;
54
+ }
55
+ /**
56
+ * The collection and repository a request's hostname names, or null.
57
+ *
58
+ * The double hyphen is an unambiguous separator precisely because neither half
59
+ * may contain one, so `a--b` in collection `c` cannot be confused with `a` in
60
+ * collection `b--c`. It also means no label can begin `xn--`, which a browser
61
+ * would read as punycode. No filesystem access happens here; the caller resolves
62
+ * the names with findRepo as usual.
63
+ */
64
+ function parseSiteHost(sitesHost, hostname) {
65
+ if (!sitesHost)
66
+ return null;
67
+ const host = normalizeHostname(hostname);
68
+ const suffix = `.${sitesHost}`;
69
+ if (!host.endsWith(suffix))
70
+ return null;
71
+ const label = host.slice(0, -suffix.length);
72
+ // Exactly one remaining label. A deeper name such as a.b.<sitesHost> is
73
+ // refused, and is not covered by a single wildcard certificate anyway.
74
+ if (label === '' || label.includes('.'))
75
+ return null;
76
+ const parts = label.split('--');
77
+ if (parts.length !== 2)
78
+ return null;
79
+ const [repo, collection] = parts;
80
+ if (!isSiteLabelSafe(repo) || !isSiteLabelSafe(collection))
81
+ return null;
82
+ return { collection, repo };
83
+ }
84
+ /**
85
+ * Whether a hostname belongs to the sites host at all, including the bare name
86
+ * and names too deep to be a site. The forge must not answer on any of them, so
87
+ * this is what the middleware checks before falling through.
88
+ */
89
+ function isUnderSitesHost(sitesHost, hostname) {
90
+ if (!sitesHost)
91
+ return false;
92
+ const host = normalizeHostname(hostname);
93
+ return host === sitesHost || host.endsWith(`.${sitesHost}`);
94
+ }