@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/lfs.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.registerLfs = registerLfs;
|
|
40
|
+
const express_1 = __importDefault(require("express"));
|
|
41
|
+
const crypto = __importStar(require("crypto"));
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const stream_1 = require("stream");
|
|
45
|
+
const promises_1 = require("stream/promises");
|
|
46
|
+
const githttp_1 = require("./githttp");
|
|
47
|
+
const lfsstore_1 = require("./lfsstore");
|
|
48
|
+
const scan_1 = require("./scan");
|
|
49
|
+
const web_1 = require("./web");
|
|
50
|
+
// The server side of the Git LFS Batch API, plus the two transfer routes used
|
|
51
|
+
// only by the local backend. The Batch API lets the server return an
|
|
52
|
+
// arbitrary URL per object transfer, so with the s3 backend we return
|
|
53
|
+
// presigned bucket URLs and large-file bytes never pass through this process.
|
|
54
|
+
//
|
|
55
|
+
// git-lfs derives its endpoint by appending `.git/info/lfs` to the remote
|
|
56
|
+
// URL; findRepo strips the optional `.git`, so both spellings resolve and no
|
|
57
|
+
// client-side configuration is needed.
|
|
58
|
+
const MAX_BATCH_OBJECTS = 1000;
|
|
59
|
+
const HEAD_CONCURRENCY = 8;
|
|
60
|
+
// Object-id validation is the security boundary of this module: every oid
|
|
61
|
+
// must match this before it is used to build a key or path, playing the same
|
|
62
|
+
// role for object ids that isValidName plays for collection and repo names.
|
|
63
|
+
const OID_RE = /^[0-9a-f]{64}$/;
|
|
64
|
+
function sendLfs(res, status, body) {
|
|
65
|
+
res
|
|
66
|
+
.status(status)
|
|
67
|
+
.set('Content-Type', 'application/vnd.git-lfs+json; charset=utf-8')
|
|
68
|
+
// Batch responses carry short-lived signed URLs; nothing in front of the
|
|
69
|
+
// server should hold on to them.
|
|
70
|
+
.set('Cache-Control', 'no-store')
|
|
71
|
+
.send(JSON.stringify(body));
|
|
72
|
+
}
|
|
73
|
+
function lfsError(res, status, message) {
|
|
74
|
+
sendLfs(res, status, { message });
|
|
75
|
+
}
|
|
76
|
+
// A reader cancelling a large download, or a client dropping mid-upload, is
|
|
77
|
+
// ordinary traffic for this module rather than a fault, and must not fill the
|
|
78
|
+
// log with stack traces.
|
|
79
|
+
function isClientAbort(e) {
|
|
80
|
+
const code = e?.code;
|
|
81
|
+
return code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECONNRESET' || code === 'ECANCELED';
|
|
82
|
+
}
|
|
83
|
+
// Async-handler wrapper that reports failures in the LFS error shape rather
|
|
84
|
+
// than the HTML error page.
|
|
85
|
+
function lah(fn) {
|
|
86
|
+
return (req, res) => {
|
|
87
|
+
fn(req, res).catch((e) => {
|
|
88
|
+
if (isClientAbort(e)) {
|
|
89
|
+
res.end();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
console.error(e);
|
|
93
|
+
if (!res.headersSent)
|
|
94
|
+
lfsError(res, 500, 'internal error');
|
|
95
|
+
else
|
|
96
|
+
res.end();
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function mapLimit(items, limit, fn) {
|
|
101
|
+
const out = new Array(items.length);
|
|
102
|
+
let next = 0;
|
|
103
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const i = next++;
|
|
106
|
+
if (i >= items.length)
|
|
107
|
+
return;
|
|
108
|
+
out[i] = await fn(items[i]);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
await Promise.all(workers);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
// The local backend signs relative hrefs (it does not know the request host);
|
|
115
|
+
// prefix the request's own base URL. Bucket hrefs are already absolute.
|
|
116
|
+
function action(a, base) {
|
|
117
|
+
return { href: a.href.startsWith('/') ? base + a.href : a.href, header: a.header, expires_in: a.expiresIn };
|
|
118
|
+
}
|
|
119
|
+
class SizeExceeded extends Error {
|
|
120
|
+
}
|
|
121
|
+
function registerLfs(app, root, lfs, authLimiter) {
|
|
122
|
+
const store = lfs?.store ?? null;
|
|
123
|
+
const maxSize = lfs?.maxSize ?? 0;
|
|
124
|
+
// Lenient about the request content type: git-lfs sends
|
|
125
|
+
// application/vnd.git-lfs+json, curl in tests may not.
|
|
126
|
+
const json = express_1.default.json({ type: () => true, limit: '5mb' });
|
|
127
|
+
// A body that will not parse is a malformed request, and must be reported
|
|
128
|
+
// in the LFS error shape rather than falling through to the HTML error
|
|
129
|
+
// page the app-level handler would render.
|
|
130
|
+
const jsonErr = (err, _req, res, next) => {
|
|
131
|
+
if (err) {
|
|
132
|
+
lfsError(res, 422, `request body is not valid JSON: ${err.message}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
next();
|
|
136
|
+
};
|
|
137
|
+
function unavailable(res) {
|
|
138
|
+
if (store)
|
|
139
|
+
return false;
|
|
140
|
+
lfsError(res, 501, 'Git LFS is not enabled on this vault');
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function sendDenied(res, check) {
|
|
144
|
+
if (check.status === 401) {
|
|
145
|
+
// LFS-Authenticate is the header git-lfs looks for.
|
|
146
|
+
res.setHeader('LFS-Authenticate', 'Basic realm="mochi"');
|
|
147
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="mochi"');
|
|
148
|
+
}
|
|
149
|
+
if (check.retryAfter !== undefined)
|
|
150
|
+
res.setHeader('Retry-After', String(check.retryAfter));
|
|
151
|
+
lfsError(res, check.status, check.message);
|
|
152
|
+
}
|
|
153
|
+
// Upload (and verify) reuse the push authorization.
|
|
154
|
+
function requireUploadAuth(req, res, repo) {
|
|
155
|
+
const check = (0, githttp_1.checkPushAuth)(root, authLimiter, req, repo.collection, repo.name, repo);
|
|
156
|
+
if (!check.ok) {
|
|
157
|
+
sendDenied(res, check);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The repository these LFS objects belong to, readable by this request, or
|
|
164
|
+
* null having sent the refusal. Reading follows the git wire's rule
|
|
165
|
+
* (checkReadAuth): public repositories serve anonymously, and a private one
|
|
166
|
+
* answers exactly as an absent one does.
|
|
167
|
+
*/
|
|
168
|
+
function requireRepoRead(req, res) {
|
|
169
|
+
const repo = (0, scan_1.findRepo)(root, req.params.collection, req.params.repo);
|
|
170
|
+
const check = (0, githttp_1.checkReadAuth)(root, authLimiter, req, repo);
|
|
171
|
+
if (!check.ok) {
|
|
172
|
+
sendDenied(res, check);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (!repo) {
|
|
176
|
+
// Unreachable: checkReadAuth answers ok only for a repository it saw.
|
|
177
|
+
lfsError(res, 404, 'repository not found');
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return repo;
|
|
181
|
+
}
|
|
182
|
+
// ---- Batch API ----
|
|
183
|
+
app.post('/:collection/:repo/info/lfs/objects/batch', json, jsonErr, lah(async (req, res) => {
|
|
184
|
+
if (unavailable(res) || !store)
|
|
185
|
+
return;
|
|
186
|
+
const repo = requireRepoRead(req, res);
|
|
187
|
+
if (!repo)
|
|
188
|
+
return;
|
|
189
|
+
const body = (req.body ?? {});
|
|
190
|
+
const operation = body.operation;
|
|
191
|
+
if (operation !== 'download' && operation !== 'upload') {
|
|
192
|
+
lfsError(res, 422, 'operation must be "download" or "upload"');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const transfers = body.transfers;
|
|
196
|
+
if (transfers !== undefined && !(Array.isArray(transfers) && transfers.includes('basic'))) {
|
|
197
|
+
lfsError(res, 422, 'only the "basic" transfer adapter is supported');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (body.hash_algo !== undefined && body.hash_algo !== 'sha256') {
|
|
201
|
+
lfsError(res, 422, 'only the sha256 hash algorithm is supported');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const objects = body.objects;
|
|
205
|
+
if (!Array.isArray(objects)) {
|
|
206
|
+
lfsError(res, 422, '"objects" must be an array');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (objects.length > MAX_BATCH_OBJECTS) {
|
|
210
|
+
lfsError(res, 422, `batches are limited to ${MAX_BATCH_OBJECTS} objects`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const parsed = [];
|
|
214
|
+
for (const o of objects) {
|
|
215
|
+
const rec = (typeof o === 'object' && o !== null ? o : {});
|
|
216
|
+
if (typeof rec.oid !== 'string' || !OID_RE.test(rec.oid)) {
|
|
217
|
+
lfsError(res, 422, 'invalid object id (expected 64 lowercase hex characters)');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (typeof rec.size !== 'number' || !Number.isSafeInteger(rec.size) || rec.size < 0) {
|
|
221
|
+
lfsError(res, 422, 'invalid object size');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
parsed.push({ oid: rec.oid, size: rec.size });
|
|
225
|
+
}
|
|
226
|
+
// Download needs no more than the read access requireRepoRead already
|
|
227
|
+
// established, matching anonymous clone: a public repository must
|
|
228
|
+
// support `git clone` plus `git lfs pull` with no credentials.
|
|
229
|
+
if (operation === 'upload' && !requireUploadAuth(req, res, repo))
|
|
230
|
+
return;
|
|
231
|
+
const base = (0, web_1.baseUrlOf)(req);
|
|
232
|
+
const authz = req.get('authorization');
|
|
233
|
+
// One storage lookup per distinct object id. Download is anonymous, so
|
|
234
|
+
// without this a single request repeating one oid a thousand times
|
|
235
|
+
// would fan out to a thousand bucket operations.
|
|
236
|
+
const distinct = [...new Map(parsed.map((o) => [o.oid, o])).values()];
|
|
237
|
+
const results = await mapLimit(distinct, HEAD_CONCURRENCY, async (o) => {
|
|
238
|
+
const info = await store.head(repo.collection, repo.name, o.oid);
|
|
239
|
+
if (operation === 'download') {
|
|
240
|
+
if (!info) {
|
|
241
|
+
// A presigned URL for a missing object would surface as a
|
|
242
|
+
// confusing mid-transfer failure; fail the object up front.
|
|
243
|
+
return { oid: o.oid, size: o.size, error: { code: 404, message: 'object not found' } };
|
|
244
|
+
}
|
|
245
|
+
const dl = await store.signDownload(repo.collection, repo.name, o.oid);
|
|
246
|
+
return {
|
|
247
|
+
oid: o.oid,
|
|
248
|
+
size: info.size,
|
|
249
|
+
authenticated: true,
|
|
250
|
+
actions: { download: action(dl, base) },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// Already present: omitting actions tells the client to skip the
|
|
254
|
+
// upload; this is the deduplication mechanism.
|
|
255
|
+
if (info)
|
|
256
|
+
return { oid: o.oid, size: o.size, authenticated: true };
|
|
257
|
+
if (o.size > maxSize) {
|
|
258
|
+
return {
|
|
259
|
+
oid: o.oid,
|
|
260
|
+
size: o.size,
|
|
261
|
+
error: { code: 422, message: `object exceeds the maximum size of ${maxSize} bytes` },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const up = await store.signUpload(repo.collection, repo.name, o.oid, o.size);
|
|
265
|
+
return {
|
|
266
|
+
oid: o.oid,
|
|
267
|
+
size: o.size,
|
|
268
|
+
authenticated: true,
|
|
269
|
+
actions: {
|
|
270
|
+
upload: action(up, base),
|
|
271
|
+
verify: {
|
|
272
|
+
href: `${base}/${encodeURIComponent(repo.collection)}/${encodeURIComponent(repo.name)}/info/lfs/objects/verify`,
|
|
273
|
+
header: authz ? { Authorization: authz } : {},
|
|
274
|
+
expires_in: up.expiresIn,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
// Answer in the shape the client asked in: one entry per requested
|
|
280
|
+
// object, in request order, even though repeats shared a lookup.
|
|
281
|
+
const byOid = new Map(results.map((r) => [r.oid, r]));
|
|
282
|
+
const out = parsed.map((o) => byOid.get(o.oid));
|
|
283
|
+
sendLfs(res, 200, { transfer: 'basic', objects: out, hash_algo: 'sha256' });
|
|
284
|
+
}));
|
|
285
|
+
// ---- verify endpoint ----
|
|
286
|
+
// Upload bytes bypass the server entirely in the s3 configuration, so this
|
|
287
|
+
// is the only integrity check available there.
|
|
288
|
+
app.post('/:collection/:repo/info/lfs/objects/verify', json, jsonErr, lah(async (req, res) => {
|
|
289
|
+
if (unavailable(res) || !store)
|
|
290
|
+
return;
|
|
291
|
+
const repo = requireRepoRead(req, res);
|
|
292
|
+
if (!repo)
|
|
293
|
+
return;
|
|
294
|
+
const body = (req.body ?? {});
|
|
295
|
+
const oid = body.oid;
|
|
296
|
+
const size = body.size;
|
|
297
|
+
if (typeof oid !== 'string' || !OID_RE.test(oid)) {
|
|
298
|
+
lfsError(res, 422, 'invalid object id (expected 64 lowercase hex characters)');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0) {
|
|
302
|
+
lfsError(res, 422, 'invalid object size');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!requireUploadAuth(req, res, repo))
|
|
306
|
+
return;
|
|
307
|
+
const info = await store.head(repo.collection, repo.name, oid);
|
|
308
|
+
if (!info) {
|
|
309
|
+
lfsError(res, 404, 'object not found');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (info.size !== size) {
|
|
313
|
+
lfsError(res, 422, `size mismatch: expected ${size} bytes, stored object has ${info.size}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
sendLfs(res, 200, { oid, size });
|
|
317
|
+
}));
|
|
318
|
+
function checkTransfer(req, res, op) {
|
|
319
|
+
if (unavailable(res) || !store)
|
|
320
|
+
return null;
|
|
321
|
+
const repo = (0, scan_1.findRepo)(root, req.params.collection, req.params.repo);
|
|
322
|
+
if (!repo) {
|
|
323
|
+
lfsError(res, 404, 'repository not found');
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const oid = req.params.oid;
|
|
327
|
+
if (!OID_RE.test(oid)) {
|
|
328
|
+
lfsError(res, 422, 'invalid object id');
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
if (store.kind !== 'local') {
|
|
332
|
+
lfsError(res, 400, 'this vault stores LFS objects in a bucket; transfer URLs point at the bucket');
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
// exp must be exactly the digits that were signed, not merely something
|
|
336
|
+
// parseInt can salvage from them: the signature covers String(exp), so
|
|
337
|
+
// accepting "1700000000zzz" would put a byte in the URL that no signature
|
|
338
|
+
// covers, contradicting the invariant the scheme rests on.
|
|
339
|
+
const rawExp = typeof req.query.exp === 'string' ? req.query.exp : '';
|
|
340
|
+
const sig = typeof req.query.sig === 'string' ? req.query.sig : '';
|
|
341
|
+
const disp = typeof req.query.disp === 'string' ? req.query.disp : '';
|
|
342
|
+
if (!/^\d{1,15}$/.test(rawExp)) {
|
|
343
|
+
lfsError(res, 403, 'malformed transfer URL');
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const exp = parseInt(rawExp, 10);
|
|
347
|
+
if (exp < Math.floor(Date.now() / 1000)) {
|
|
348
|
+
lfsError(res, 403, 'this transfer URL has expired');
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (!(0, lfsstore_1.verifyTransfer)(root, op, repo.collection, repo.name, oid, exp, disp, sig)) {
|
|
352
|
+
lfsError(res, 403, 'invalid transfer signature');
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return { collection: repo.collection, repo: repo.name, oid, disp };
|
|
356
|
+
}
|
|
357
|
+
app.get('/:collection/:repo/info/lfs/objects/:oid', lah(async (req, res) => {
|
|
358
|
+
const t = checkTransfer(req, res, 'download');
|
|
359
|
+
if (!t)
|
|
360
|
+
return;
|
|
361
|
+
const file = (0, lfsstore_1.localObjectPath)(root, t.collection, t.repo, t.oid);
|
|
362
|
+
let st;
|
|
363
|
+
try {
|
|
364
|
+
st = fs.statSync(file);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
lfsError(res, 404, 'object not found');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// These bytes are repository content served from the vault's own
|
|
371
|
+
// origin, so they get the same treatment as the raw route in
|
|
372
|
+
// src/browse.ts: a sandbox CSP and an attachment disposition, so an
|
|
373
|
+
// uploaded HTML or SVG payload can never run as script here. The
|
|
374
|
+
// filename is only present on hrefs the raw route signed.
|
|
375
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
376
|
+
res.setHeader('Content-Length', String(st.size));
|
|
377
|
+
res.setHeader('Content-Security-Policy', 'sandbox');
|
|
378
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
379
|
+
res.setHeader('Content-Disposition', t.disp === '' ? 'attachment' : `attachment; filename="${t.disp}"`);
|
|
380
|
+
await (0, promises_1.pipeline)(fs.createReadStream(file), res);
|
|
381
|
+
}));
|
|
382
|
+
app.put('/:collection/:repo/info/lfs/objects/:oid', lah(async (req, res) => {
|
|
383
|
+
const t = checkTransfer(req, res, 'upload');
|
|
384
|
+
if (!t)
|
|
385
|
+
return;
|
|
386
|
+
const file = (0, lfsstore_1.localObjectPath)(root, t.collection, t.repo, t.oid);
|
|
387
|
+
const dir = path.dirname(file);
|
|
388
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
389
|
+
// Stream to a temporary file and rename into place, so a failed or
|
|
390
|
+
// interrupted upload never leaves a corrupt object at the final path.
|
|
391
|
+
// The rename is atomic, so two clients uploading the same object
|
|
392
|
+
// concurrently are harmless: the content is identical by construction.
|
|
393
|
+
const tmp = path.join(dir, `tmp-${crypto.randomBytes(8).toString('hex')}`);
|
|
394
|
+
const hash = crypto.createHash('sha256');
|
|
395
|
+
let bytes = 0;
|
|
396
|
+
const hasher = new stream_1.Transform({
|
|
397
|
+
transform(chunk, _enc, cb) {
|
|
398
|
+
bytes += chunk.length;
|
|
399
|
+
// The signature binds the oid but not a size, so cap what a signed
|
|
400
|
+
// URL can write to disk.
|
|
401
|
+
if (bytes > maxSize) {
|
|
402
|
+
cb(new SizeExceeded());
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
hash.update(chunk);
|
|
406
|
+
cb(null, chunk);
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
try {
|
|
410
|
+
await (0, promises_1.pipeline)(req, hasher, fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 }));
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
fs.rmSync(tmp, { force: true });
|
|
414
|
+
if (e instanceof SizeExceeded) {
|
|
415
|
+
lfsError(res, 422, `object exceeds the maximum size of ${maxSize} bytes`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
throw e;
|
|
419
|
+
}
|
|
420
|
+
const digest = hash.digest('hex');
|
|
421
|
+
if (digest !== t.oid) {
|
|
422
|
+
fs.rmSync(tmp, { force: true });
|
|
423
|
+
lfsError(res, 422, `content hashes to ${digest}, not the requested object id`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
fs.renameSync(tmp, file);
|
|
427
|
+
res.status(200).end();
|
|
428
|
+
}));
|
|
429
|
+
// ---- locking API: deliberately unsupported ----
|
|
430
|
+
// git-lfs probes /locks/verify at push time and treats a 404 as "locking
|
|
431
|
+
// not supported", which is the answer we want.
|
|
432
|
+
app.all('/:collection/:repo/info/lfs/locks', (_req, res) => {
|
|
433
|
+
lfsError(res, 404, 'the LFS file locking API is not supported');
|
|
434
|
+
});
|
|
435
|
+
app.all('/:collection/:repo/info/lfs/locks/*', (_req, res) => {
|
|
436
|
+
lfsError(res, 404, 'the LFS file locking API is not supported');
|
|
437
|
+
});
|
|
438
|
+
}
|