@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.
- package/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- 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
|
+
}
|