@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
package/dist/browse.js ADDED
@@ -0,0 +1,630 @@
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.IMAGE_TYPES = void 0;
37
+ exports.registerBrowse = registerBrowse;
38
+ const crypto = __importStar(require("crypto"));
39
+ const fs = __importStar(require("fs"));
40
+ const git_1 = require("./git");
41
+ const languages_1 = require("./languages");
42
+ const markdown_1 = require("./markdown");
43
+ const pointer_1 = require("./pointer");
44
+ const profile_1 = require("./profile");
45
+ const render_1 = require("./render");
46
+ const atom_1 = require("./atom");
47
+ const runs_1 = require("./ci/runs");
48
+ const diff_1 = require("./diff");
49
+ const layout_1 = require("./layout");
50
+ const perms_1 = require("./perms");
51
+ const scan_1 = require("./scan");
52
+ const session_1 = require("./session");
53
+ const site_1 = require("./site");
54
+ const vault_1 = require("./vault");
55
+ const views = __importStar(require("./views"));
56
+ const views_1 = require("./views");
57
+ const web_1 = require("./web");
58
+ const COMMITS_PER_PAGE = 35;
59
+ const MAX_RENDER_SIZE = 1024 * 1024;
60
+ const MAX_LISTED_COMMITS = 250;
61
+ const ARCHIVE_FORMATS = {
62
+ 'tar.gz': { format: 'tar.gz', type: 'application/gzip' },
63
+ tgz: { format: 'tar.gz', type: 'application/gzip' },
64
+ zip: { format: 'zip', type: 'application/zip' },
65
+ };
66
+ exports.IMAGE_TYPES = {
67
+ png: 'image/png',
68
+ jpg: 'image/jpeg',
69
+ jpeg: 'image/jpeg',
70
+ gif: 'image/gif',
71
+ webp: 'image/webp',
72
+ ico: 'image/x-icon',
73
+ svg: 'image/svg+xml',
74
+ };
75
+ function registerBrowse(app, root, gates, lfs = null) {
76
+ /**
77
+ * What a listing says about one repository. The facts beyond the name come
78
+ * from different places, so they are gathered once here and used by both
79
+ * the front page and a collection's own. A private repository the viewer
80
+ * has no role on is not listed at all, matching the 404 its page gives.
81
+ */
82
+ async function repoCards(req, collection, viewer) {
83
+ const visible = (0, scan_1.listRepoDirs)(root, collection).filter((d) => (0, perms_1.repoRole)(root, viewer?.auth ?? null, {
84
+ collection,
85
+ name: (0, scan_1.displayName)(d),
86
+ dir: (0, layout_1.repoPath)(root, collection, d),
87
+ }) !== null);
88
+ return Promise.all(visible.map(async (d) => {
89
+ const name = (0, scan_1.displayName)(d);
90
+ const repo = new git_1.GitRepo((0, layout_1.repoPath)(root, collection, d), collection, name);
91
+ // A repository with a site is linked straight to it from the listing,
92
+ // at its own origin where it has one, so a visitor scanning a
93
+ // collection reaches the published page without stopping at the
94
+ // repository first.
95
+ const hasSite = (0, scan_1.siteDir)(root, collection, name) !== null;
96
+ const origin = hasSite ? (0, site_1.siteHostUrl)(root, req, collection, name) : null;
97
+ const run = (0, runs_1.latestRun)(root, collection, name);
98
+ return {
99
+ collection,
100
+ name,
101
+ description: (0, scan_1.repoDescription)(repo.dir),
102
+ isPrivate: (0, perms_1.repoIsPrivate)(repo.dir),
103
+ updated: await repo.lastUpdated(),
104
+ siteUrl: !hasSite
105
+ ? null
106
+ : origin
107
+ ? `${origin}/`
108
+ : `/${encodeURIComponent(collection)}/${encodeURIComponent(name)}/site/`,
109
+ ci: run
110
+ ? {
111
+ conclusion: run.conclusion ?? null,
112
+ running: run.status !== 'completed',
113
+ url: `/${encodeURIComponent(collection)}/${encodeURIComponent(name)}/actions/runs/${run.number}`,
114
+ }
115
+ : null,
116
+ };
117
+ }));
118
+ }
119
+ // Newest first is the default: a listing is read to see what has been
120
+ // happening, and only sometimes to find a name already known.
121
+ function sortParam(req) {
122
+ return req.query.sort === 'name' ? 'name' : 'recent';
123
+ }
124
+ app.get('/', (0, web_1.ah)(async (req, res) => {
125
+ const viewer = (0, session_1.getViewer)(req, root);
126
+ const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
127
+ // The counts beside collection names count what this viewer can see,
128
+ // for the same reason the cards do.
129
+ const collections = (0, scan_1.listCollections)(root).map((c, i) => ({ name: c.name, repoCount: cardsPer[i].length }));
130
+ res.type('html').send(views.homePage(root, collections, cardsPer.flat(), sortParam(req), viewer));
131
+ }));
132
+ app.get('/about', (req, res) => {
133
+ res.type('html').send(views.aboutPage((0, web_1.baseUrlOf)(req), (0, session_1.getViewer)(req, root)));
134
+ });
135
+ app.get('/:collection', (0, web_1.ah)(async (req, res) => {
136
+ const collection = req.params.collection;
137
+ const viewer = (0, session_1.getViewer)(req, root);
138
+ let collectionIsDir = false;
139
+ try {
140
+ collectionIsDir = fs.statSync((0, layout_1.collectionDir)(root, collection)).isDirectory();
141
+ }
142
+ catch {
143
+ collectionIsDir = false;
144
+ }
145
+ if (!(0, scan_1.isValidName)(collection) || !collectionIsDir) {
146
+ (0, web_1.send404)(res, `Collection ${collection} not found`, viewer);
147
+ return;
148
+ }
149
+ // The profile lives in the .mochi repository, so a private one
150
+ // introduces the collection only to viewers who could read it.
151
+ const profileRepo = (0, scan_1.findRepo)(root, collection, '.mochi');
152
+ const profileVisible = profileRepo === null || (0, perms_1.repoRole)(root, viewer?.auth ?? null, profileRepo) !== null;
153
+ const [cards, profile] = await Promise.all([
154
+ repoCards(req, collection, viewer),
155
+ profileVisible
156
+ ? (0, profile_1.collectionProfile)(root, collection)
157
+ : Promise.resolve({ readme: null, addUrl: `/new?collection=${encodeURIComponent(collection)}` }),
158
+ ]);
159
+ const canSettings = viewer !== null && (0, perms_1.canAdminCollection)(root, viewer.auth, collection);
160
+ res
161
+ .type('html')
162
+ .send(views.collectionPage(collection, cards, sortParam(req), viewer, canSettings, profile));
163
+ }));
164
+ async function renderTree(req, res, loaded, ref, treePath) {
165
+ const viewer = (0, session_1.getViewer)(req, root);
166
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, ref, viewer);
167
+ let entries;
168
+ try {
169
+ entries = await loaded.repo.listTree(ref, treePath);
170
+ }
171
+ catch {
172
+ (0, web_1.send404)(res, `Path ${treePath || '/'} not found at ${ref}`, viewer);
173
+ return;
174
+ }
175
+ // The listing wants a commit per entry, which is a git log per entry: cheap
176
+ // for the directory sizes people browse, and capped so that an unusually
177
+ // wide one degrades into a listing without the message and age columns
178
+ // rather than into a page that takes a second to build.
179
+ const entryPaths = entries
180
+ .slice(0, MAX_LISTED_COMMITS)
181
+ .map((e) => (treePath === '' ? e.name : `${treePath}/${e.name}`));
182
+ // The language breakdown reads the whole tree, so it is measured only at
183
+ // the root, which is the only place the About panel that shows it appears.
184
+ const [latest, entryCommits, commitCount, languages, contributors] = await Promise.all([
185
+ loaded.repo.log(ref, 0, 1, treePath || undefined).then((cs) => cs[0] ?? null),
186
+ loaded.repo.lastCommits(ref, entryPaths),
187
+ loaded.repo.commitCount(ref).catch(() => 0),
188
+ treePath === '' ? (0, languages_1.languageBreakdown)(loaded.repo.dir, ref) : Promise.resolve([]),
189
+ treePath === ''
190
+ ? loaded.repo.contributors(ref).then((people) => {
191
+ // One person, one face: a user's web edits carry a synthetic
192
+ // author, and their listed emails are theirs too.
193
+ const state = (0, vault_1.loadVault)(root);
194
+ return (0, vault_1.mergeContributors)(state.status === 'ok' ? state.vault : null, people);
195
+ })
196
+ : Promise.resolve([]),
197
+ ]);
198
+ let readmeHtml = null;
199
+ let readmeName = null;
200
+ const readme = entries.find((e) => e.type === 'blob' && /^readme(\.(md|markdown|txt))?$/i.test(e.name));
201
+ if (readme && (readme.size ?? 0) <= MAX_RENDER_SIZE) {
202
+ const readmePath = treePath === '' ? readme.name : `${treePath}/${readme.name}`;
203
+ const buf = await loaded.repo.catBlob(ref, readmePath);
204
+ if (!(0, render_1.isBinary)(buf)) {
205
+ const text = buf.toString('utf8');
206
+ readmeName = readme.name;
207
+ const base = (0, views_1.repoUrl)(ctx);
208
+ const dirSuffix = treePath === '' ? '' : `/${(0, views_1.encPath)(treePath)}`;
209
+ if (/\.(md|markdown)$/i.test(readme.name) || !readme.name.includes('.')) {
210
+ readmeHtml = (0, markdown_1.renderMarkdown)(text, {
211
+ rawBase: `${base}/raw/${(0, views_1.encPath)(ref)}${dirSuffix}`,
212
+ blobBase: `${base}/blob/${(0, views_1.encPath)(ref)}${dirSuffix}`,
213
+ issueBase: `${base}/issues`,
214
+ commitBase: `${base}/commit`,
215
+ });
216
+ }
217
+ else {
218
+ readmeHtml = `<pre>${(0, render_1.esc)(text)}</pre>`;
219
+ }
220
+ }
221
+ }
222
+ res.type('html').send(views.treePage(ctx, {
223
+ path: treePath,
224
+ entries,
225
+ entryCommits,
226
+ latest,
227
+ commitCount,
228
+ description: (0, scan_1.repoDescription)(loaded.repo.dir),
229
+ contributors,
230
+ readmeHtml,
231
+ readmeName,
232
+ languages,
233
+ }));
234
+ }
235
+ app.get('/:collection/:repo', (0, web_1.ah)(async (req, res) => {
236
+ const viewer = (0, session_1.getViewer)(req, root);
237
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
238
+ if (!loaded)
239
+ return;
240
+ if (!loaded.defaultBranch) {
241
+ res.type('html').send(views.emptyRepoPage(await (0, web_1.makeCtx)(root, req, loaded, '', viewer)));
242
+ return;
243
+ }
244
+ await renderTree(req, res, loaded, loaded.defaultBranch, '');
245
+ }));
246
+ app.get('/:collection/:repo/tree/*', (0, web_1.ah)(async (req, res) => {
247
+ const viewer = (0, session_1.getViewer)(req, root);
248
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
249
+ if (!loaded)
250
+ return;
251
+ const { ref, path: treePath } = loaded.repo.resolveRefAndPath((0, web_1.wildcard)(req), loaded.refNames);
252
+ if (!(0, git_1.isValidRefName)(ref) || !(0, git_1.isValidRepoPath)(treePath)) {
253
+ (0, web_1.send404)(res, 'Not found', viewer);
254
+ return;
255
+ }
256
+ if (treePath !== '') {
257
+ const type = await loaded.repo.entryType(ref, treePath);
258
+ if (type === 'blob') {
259
+ res.redirect(`${(0, views_1.repoUrl)(await (0, web_1.makeCtx)(root, req, loaded, ref, viewer))}/blob/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(treePath)}`);
260
+ return;
261
+ }
262
+ }
263
+ await renderTree(req, res, loaded, ref, treePath);
264
+ }));
265
+ app.get('/:collection/:repo/blob/*', (0, web_1.ah)(async (req, res) => {
266
+ const viewer = (0, session_1.getViewer)(req, root);
267
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
268
+ if (!loaded)
269
+ return;
270
+ const { ref, path: filePath } = loaded.repo.resolveRefAndPath((0, web_1.wildcard)(req), loaded.refNames);
271
+ if (!(0, git_1.isValidRefName)(ref) || !(0, git_1.isValidRepoPath)(filePath) || filePath === '') {
272
+ (0, web_1.send404)(res, 'Not found', viewer);
273
+ return;
274
+ }
275
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, ref, viewer);
276
+ const type = await loaded.repo.entryType(ref, filePath);
277
+ if (type === 'tree') {
278
+ res.redirect(`${(0, views_1.repoUrl)(ctx)}/tree/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`);
279
+ return;
280
+ }
281
+ if (type !== 'blob') {
282
+ (0, web_1.send404)(res, `File ${filePath} not found at ${ref}`, viewer);
283
+ return;
284
+ }
285
+ const buf = await loaded.repo.catBlob(ref, filePath);
286
+ const ext = (filePath.split('.').pop() ?? '').toLowerCase();
287
+ const rawUrl = `${(0, views_1.repoUrl)(ctx)}/raw/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`;
288
+ // LFS pointer detection precedes every content branch: an LFS-tracked
289
+ // .md or .png must render as a download card, not as its pointer text.
290
+ // ?plain=1 falls through to the source view, as on GitHub, keeping the
291
+ // underlying pointer inspectable.
292
+ const pointer = (0, pointer_1.parsePointer)(buf);
293
+ if (pointer) {
294
+ if (req.query.plain !== '1') {
295
+ res
296
+ .type('html')
297
+ .send(views.blobPage(ctx, filePath, { kind: 'lfs', rawUrl, size: pointer.size, oid: pointer.oid }, true));
298
+ return;
299
+ }
300
+ // ?plain=1 shows the pointer itself, whatever the file is named: an
301
+ // LFS-tracked .png must not be rendered from its pointer text as an
302
+ // image, or the source view would be unreachable for it. Pointers are
303
+ // never editable, so no edit controls here.
304
+ const src = buf.toString('utf8');
305
+ res.type('html').send(views.blobPage(ctx, filePath, {
306
+ kind: 'code',
307
+ html: (0, render_1.esc)(src),
308
+ lineCount: src.replace(/\n$/, '').split('\n').length,
309
+ size: buf.length,
310
+ editable: false,
311
+ }, true));
312
+ return;
313
+ }
314
+ if (exports.IMAGE_TYPES[ext]) {
315
+ res.type('html').send(views.blobPage(ctx, filePath, { kind: 'image', rawUrl, size: buf.length }));
316
+ return;
317
+ }
318
+ if ((0, render_1.isBinary)(buf)) {
319
+ res.type('html').send(views.blobPage(ctx, filePath, { kind: 'binary', rawUrl, size: buf.length }));
320
+ return;
321
+ }
322
+ if (buf.length > MAX_RENDER_SIZE) {
323
+ res.type('html').send(views.blobPage(ctx, filePath, { kind: 'too-large', rawUrl, size: buf.length }));
324
+ return;
325
+ }
326
+ const text = buf.toString('utf8');
327
+ const editable = ctx.canPush && ctx.refIsBranch;
328
+ const markdown = (0, markdown_1.isMarkdownFile)(filePath);
329
+ // Markdown renders by default; ?plain=1 asks for the source, as on GitHub.
330
+ if (markdown && req.query.plain !== '1') {
331
+ const dir = filePath.includes('/') ? `/${(0, views_1.encPath)(filePath.slice(0, filePath.lastIndexOf('/')))}` : '';
332
+ const html = (0, markdown_1.renderMarkdown)(text, {
333
+ rawBase: `${(0, views_1.repoUrl)(ctx)}/raw/${(0, views_1.encPath)(ref)}${dir}`,
334
+ blobBase: `${(0, views_1.repoUrl)(ctx)}/blob/${(0, views_1.encPath)(ref)}${dir}`,
335
+ issueBase: `${(0, views_1.repoUrl)(ctx)}/issues`,
336
+ commitBase: `${(0, views_1.repoUrl)(ctx)}/commit`,
337
+ });
338
+ res
339
+ .type('html')
340
+ .send(views.blobPage(ctx, filePath, { kind: 'markdown', html, size: buf.length, editable }, true));
341
+ return;
342
+ }
343
+ const html = (0, render_1.highlightCode)(text, filePath);
344
+ const lineCount = text === '' ? 1 : text.replace(/\n$/, '').split('\n').length;
345
+ res
346
+ .type('html')
347
+ .send(views.blobPage(ctx, filePath, { kind: 'code', html, lineCount, size: buf.length, editable }, markdown));
348
+ }));
349
+ // Blame: who last changed each line, and the way back to the revision
350
+ // before that change. Only for a file we would show as text anyway; the
351
+ // rest redirect to the blob page, which explains what they are.
352
+ app.get('/:collection/:repo/blame/*', (0, web_1.ah)(async (req, res) => {
353
+ const viewer = (0, session_1.getViewer)(req, root);
354
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
355
+ if (!loaded)
356
+ return;
357
+ const { ref, path: filePath } = loaded.repo.resolveRefAndPath((0, web_1.wildcard)(req), loaded.refNames);
358
+ if (!(0, git_1.isValidRefName)(ref) || !(0, git_1.isValidRepoPath)(filePath) || filePath === '') {
359
+ (0, web_1.send404)(res, 'Not found', viewer);
360
+ return;
361
+ }
362
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, ref, viewer);
363
+ const blobUrl = `${(0, views_1.repoUrl)(ctx)}/blob/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`;
364
+ if ((await loaded.repo.entryType(ref, filePath)) !== 'blob') {
365
+ (0, web_1.send404)(res, `File ${filePath} not found at ${ref}`, viewer);
366
+ return;
367
+ }
368
+ const buf = await loaded.repo.catBlob(ref, filePath);
369
+ if ((0, render_1.isBinary)(buf) || buf.length > MAX_RENDER_SIZE) {
370
+ res.redirect(blobUrl);
371
+ return;
372
+ }
373
+ const lines = await loaded.repo.blame(ref, filePath);
374
+ const text = lines.map((l) => l.text).join('\n');
375
+ res.type('html').send(views.blamePage(ctx, filePath, (0, render_1.highlightCode)(text, filePath), lines, buf.length));
376
+ }));
377
+ app.get('/:collection/:repo/raw/*', (0, web_1.ah)(async (req, res) => {
378
+ const loaded = await (0, web_1.loadRepo)(root, req, res, (0, session_1.getViewer)(req, root));
379
+ if (!loaded)
380
+ return;
381
+ const { ref, path: filePath } = loaded.repo.resolveRefAndPath((0, web_1.wildcard)(req), loaded.refNames);
382
+ if (!(0, git_1.isValidRefName)(ref) || !(0, git_1.isValidRepoPath)(filePath) || filePath === '') {
383
+ (0, web_1.send404)(res);
384
+ return;
385
+ }
386
+ const type = await loaded.repo.entryType(ref, filePath);
387
+ if (type !== 'blob') {
388
+ res.status(404).type('text/plain').send('not found');
389
+ return;
390
+ }
391
+ const buf = await loaded.repo.catBlob(ref, filePath);
392
+ // A pointer blob redirects to the stored object; the filename gives the
393
+ // browser something better to save than a 64-character object id.
394
+ const pointer = (0, pointer_1.parsePointer)(buf);
395
+ if (pointer && lfs) {
396
+ const info = await lfs.store.head(loaded.repo.collection, loaded.repo.name, pointer.oid);
397
+ if (!info) {
398
+ res
399
+ .status(404)
400
+ .type('text/plain')
401
+ .send('This file is stored with Git LFS, but its object is missing from storage (the commits were pushed without pushing the LFS objects).\n');
402
+ return;
403
+ }
404
+ const dl = await lfs.store.signDownload(loaded.repo.collection, loaded.repo.name, pointer.oid, {
405
+ filename: filePath.split('/').pop(),
406
+ });
407
+ res.redirect(302, dl.href);
408
+ return;
409
+ }
410
+ // Reading a public repository is anonymous, so its raw files may be
411
+ // cached publicly; the question is only for how long. A full commit id
412
+ // can never come to name different bytes, so under one the answer is
413
+ // forever. Under a branch or tag it is "until it changes", which HTTP
414
+ // spells as revalidate every time: the ETag is a hash of the bytes
415
+ // themselves, so an unchanged file costs a 304 and no body, whichever
416
+ // commit now holds it. The pointer redirect above returns before this
417
+ // on purpose: a redirect to a presigned URL expires and must not be
418
+ // cached. A private repository's bytes were served to one reader and
419
+ // must never come out of a shared cache for another.
420
+ const cacheScope = (0, perms_1.repoIsPrivate)(loaded.repo.dir) ? 'private' : 'public';
421
+ if (/^[0-9a-f]{40}$/.test(ref)) {
422
+ res.setHeader('Cache-Control', `${cacheScope}, max-age=31536000, immutable`);
423
+ }
424
+ else {
425
+ res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
426
+ res.setHeader('ETag', `"${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}"`);
427
+ if (req.fresh) {
428
+ res.status(304).end();
429
+ return;
430
+ }
431
+ }
432
+ const ext = (filePath.split('.').pop() ?? '').toLowerCase();
433
+ // Repository content must never be able to inject HTML into this
434
+ // origin: non-image types are served as text/plain in a sandbox.
435
+ res.setHeader('Content-Security-Policy', 'sandbox');
436
+ res.setHeader('X-Content-Type-Options', 'nosniff');
437
+ if (exports.IMAGE_TYPES[ext]) {
438
+ res.type(exports.IMAGE_TYPES[ext]);
439
+ }
440
+ else if ((0, render_1.isBinary)(buf)) {
441
+ res.type('application/octet-stream');
442
+ }
443
+ else {
444
+ res.type('text/plain; charset=utf-8');
445
+ }
446
+ res.send(buf);
447
+ }));
448
+ // Source downloads, as GitHub's Code button offers them: the extension on
449
+ // the URL picks the format and the rest of it is the ref.
450
+ app.get('/:collection/:repo/archive/*', (0, web_1.ah)(async (req, res) => {
451
+ const viewer = (0, session_1.getViewer)(req, root);
452
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
453
+ if (!loaded)
454
+ return;
455
+ const m = (0, web_1.wildcard)(req).match(/^(.+)\.(tar\.gz|tgz|zip)$/);
456
+ if (!m) {
457
+ (0, web_1.send404)(res, 'Ask for an archive as <ref>.tar.gz or <ref>.zip', viewer);
458
+ return;
459
+ }
460
+ const [, ref, ext] = m;
461
+ const spec = ARCHIVE_FORMATS[ext];
462
+ // A ref this repository has, or a commit id: never an arbitrary
463
+ // revision expression out of a URL.
464
+ const known = loaded.refNames.includes(ref) || /^[0-9a-f]{7,40}$/.test(ref);
465
+ const sha = (0, git_1.isValidRefName)(ref) && known ? await loaded.repo.resolve(ref) : null;
466
+ if (!sha) {
467
+ (0, web_1.send404)(res, `Ref ${ref} not found`, viewer);
468
+ return;
469
+ }
470
+ // An archive is a subprocess and a stream, which is exactly what a 304
471
+ // saves: the commit the ref resolves to determines every byte of the
472
+ // archive, so it is the validator, checked before a slot is taken or
473
+ // git is spawned. An archive of a full commit id is immutable outright.
474
+ const cacheScope = (0, perms_1.repoIsPrivate)(loaded.repo.dir) ? 'private' : 'public';
475
+ if (ref === sha) {
476
+ res.setHeader('Cache-Control', `${cacheScope}, max-age=31536000, immutable`);
477
+ }
478
+ else {
479
+ res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
480
+ res.setHeader('ETag', `"${sha}"`);
481
+ if (req.fresh) {
482
+ res.status(304).end();
483
+ return;
484
+ }
485
+ }
486
+ // The slot is held until the stream ends, which is what the gate is for: an
487
+ // archive holds a subprocess and a socket for as long as the client cares
488
+ // to read.
489
+ const release = await gates.tree.enter();
490
+ if (!release) {
491
+ (0, web_1.sendBusy)(res, viewer);
492
+ return;
493
+ }
494
+ res.on('close', release);
495
+ const stem = `${loaded.repo.name}-${ref.replace(/\//g, '-')}`;
496
+ res.type(spec.type);
497
+ res.setHeader('Content-Disposition', `attachment; filename="${stem}.${ext}"`);
498
+ try {
499
+ await loaded.repo.archiveTo(ref, spec.format, `${stem}/`, res);
500
+ res.end();
501
+ }
502
+ catch {
503
+ // The response is already streaming, so there is no status left to
504
+ // send: break the connection rather than finish a truncated archive.
505
+ res.destroy();
506
+ }
507
+ finally {
508
+ release();
509
+ }
510
+ }));
511
+ app.get('/:collection/:repo/commits', (req, res) => {
512
+ res.redirect(`/${encodeURIComponent(req.params.collection)}/${encodeURIComponent(req.params.repo)}`);
513
+ });
514
+ app.get('/:collection/:repo/commits/*', (0, web_1.ah)(async (req, res) => {
515
+ const viewer = (0, session_1.getViewer)(req, root);
516
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
517
+ if (!loaded)
518
+ return;
519
+ // A path after the ref narrows the history to that file or directory,
520
+ // which is what the History button on a blob or tree page asks for.
521
+ // A .atom suffix asks for the same history as a feed, as on GitHub.
522
+ const asked = (0, web_1.wildcard)(req);
523
+ const wantsFeed = asked.endsWith('.atom');
524
+ const { ref, path: histPath } = loaded.repo.resolveRefAndPath(wantsFeed ? asked.slice(0, -'.atom'.length) : asked, loaded.refNames);
525
+ if (!(0, git_1.isValidRefName)(ref) || !(0, git_1.isValidRepoPath)(histPath)) {
526
+ (0, web_1.send404)(res, 'Not found', viewer);
527
+ return;
528
+ }
529
+ const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10) || 1);
530
+ // An author narrows it further: this is where a contributor in the
531
+ // About panel leads, and it is a literal string rather than a pattern.
532
+ const author = String(req.query.author ?? '').slice(0, 200) || undefined;
533
+ let total;
534
+ try {
535
+ total = await loaded.repo.commitCount(ref, histPath || undefined, author);
536
+ }
537
+ catch {
538
+ (0, web_1.send404)(res, `Ref ${ref} not found`, viewer);
539
+ return;
540
+ }
541
+ const totalPages = Math.max(1, Math.ceil(total / COMMITS_PER_PAGE));
542
+ const commits = await loaded.repo.log(ref, (page - 1) * COMMITS_PER_PAGE, COMMITS_PER_PAGE, histPath || undefined, author);
543
+ if (wantsFeed) {
544
+ const site = `${(0, web_1.baseUrlOf)(req)}/${encodeURIComponent(loaded.repo.collection)}/${encodeURIComponent(loaded.repo.name)}`;
545
+ const where = `${(0, views_1.encPath)(ref)}${histPath ? `/${(0, views_1.encPath)(histPath)}` : ''}`;
546
+ res.type('application/atom+xml; charset=utf-8').send((0, atom_1.atomFeed)({
547
+ id: `${site}/commits/${where}`,
548
+ title: `${loaded.repo.collection}/${loaded.repo.name}${histPath ? `: ${histPath}` : ''} at ${ref}`,
549
+ selfLink: `${site}/commits/${where}.atom`,
550
+ htmlLink: `${site}/commits/${where}`,
551
+ entries: commits.map((c) => ({
552
+ id: `${site}/commit/${c.sha}`,
553
+ title: c.subject,
554
+ updated: c.date,
555
+ link: `${site}/commit/${c.sha}`,
556
+ author: c.author,
557
+ })),
558
+ }));
559
+ return;
560
+ }
561
+ res
562
+ .type('html')
563
+ .send(views.commitsPage(await (0, web_1.makeCtx)(root, req, loaded, ref, viewer), histPath, commits, page, totalPages, total, author));
564
+ }));
565
+ app.get('/:collection/:repo/commit/:sha', (0, web_1.ah)(async (req, res) => {
566
+ const viewer = (0, session_1.getViewer)(req, root);
567
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
568
+ if (!loaded)
569
+ return;
570
+ const sha = req.params.sha;
571
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
572
+ (0, web_1.send404)(res, 'Not found', viewer);
573
+ return;
574
+ }
575
+ const detail = await loaded.repo.commit(sha);
576
+ if (!detail) {
577
+ (0, web_1.send404)(res, `Commit ${sha} not found`, viewer);
578
+ return;
579
+ }
580
+ const patch = await loaded.repo.commitPatch(detail.sha);
581
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? detail.sha, viewer);
582
+ res
583
+ .type('html')
584
+ .send(views.commitPage(ctx, detail, (0, diff_1.renderDiff)(patch, { blobBase: `${(0, views_1.repoUrl)(ctx)}/blob/${detail.sha}` })));
585
+ }));
586
+ app.get('/:collection/:repo/branches', (0, web_1.ah)(async (req, res) => {
587
+ const viewer = (0, session_1.getViewer)(req, root);
588
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
589
+ if (!loaded)
590
+ return;
591
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
592
+ res.type('html').send(views.refListPage(ctx, 'branches'));
593
+ }));
594
+ app.get('/:collection/:repo/tags', (0, web_1.ah)(async (req, res) => {
595
+ const viewer = (0, session_1.getViewer)(req, root);
596
+ const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
597
+ if (!loaded)
598
+ return;
599
+ const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
600
+ res.type('html').send(views.refListPage(ctx, 'tags'));
601
+ }));
602
+ // Sites are served through src/site.ts, which both this route and the
603
+ // per-site hostname share. The mode is what differs: on the forge's own
604
+ // origin a site is sandboxed, because its script would otherwise run as the
605
+ // visitor.
606
+ //
607
+ // When the vault has a sites host and this repository is eligible for one,
608
+ // the site lives at its own origin and this path only points there. 302 and
609
+ // not 301: a permanent redirect would be cached hard, and removing
610
+ // sites.host from config.json must take effect on the next request.
611
+ app.get('/:collection/:repo/site/*', (req, res) => {
612
+ const origin = (0, site_1.siteHostUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
613
+ if (origin) {
614
+ const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
615
+ res.redirect(302, `${origin}/${(0, web_1.wildcard)(req)}${query}`);
616
+ return;
617
+ }
618
+ (0, site_1.serveSite)(root, req.params.collection, req.params.repo, req, res, 'sandbox');
619
+ });
620
+ // The missing-slash redirect lands on the site origin in one hop rather than
621
+ // two when there is one.
622
+ app.get('/:collection/:repo/site', (req, res) => {
623
+ const origin = (0, site_1.siteHostUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
624
+ if (origin) {
625
+ res.redirect(302, `${origin}/`);
626
+ return;
627
+ }
628
+ res.redirect(`/${encodeURIComponent(req.params.collection)}/${encodeURIComponent((0, scan_1.displayName)(req.params.repo))}/site/`);
629
+ });
630
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "commit": "7b95ca5-dirty",
3
+ "builtAt": "2026-08-21T14:34:24.115Z"
4
+ }