@blamejs/core 0.7.88 → 0.7.90
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/CHANGELOG.md +4 -0
- package/index.js +2 -0
- package/lib/auth/passkey.js +94 -4
- package/lib/middleware/dpop.js +114 -5
- package/lib/middleware/index.js +5 -0
- package/lib/middleware/tus-upload.js +654 -0
- package/lib/outbox.js +399 -0
- package/lib/parsers/safe-ini.js +2 -1
- package/lib/safe-buffer.js +7 -0
- package/lib/webhook.js +1 -1
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* TUS resumable upload middleware (tus.io v1.0.0).
|
|
4
|
+
*
|
|
5
|
+
* var tus = b.middleware.tusUpload({
|
|
6
|
+
* mountPath: "/uploads",
|
|
7
|
+
* store: b.middleware.tusUpload.memoryStore({ maxSize: C.BYTES.gib(2) }),
|
|
8
|
+
* maxSize: C.BYTES.gib(2),
|
|
9
|
+
* maxChunkSize: C.BYTES.mib(64),
|
|
10
|
+
* expirationSec: C.TIME.hours(24) / 1000,
|
|
11
|
+
* extensions: ["creation", "creation-with-upload", "expiration",
|
|
12
|
+
* "checksum", "termination"],
|
|
13
|
+
* checksumAlgorithms: ["sha3-512", "shake256"],
|
|
14
|
+
* onComplete: async function (uploadId, meta) { ... },
|
|
15
|
+
* audit: true,
|
|
16
|
+
* });
|
|
17
|
+
* router.use(tus);
|
|
18
|
+
*
|
|
19
|
+
* Wire-shape per tus.io 1.0.0 §2:
|
|
20
|
+
* POST <mountPath> → 201 + Location: <mountPath>/<id>
|
|
21
|
+
* HEAD <mountPath>/<id> → 200 + Upload-Offset, Upload-Length, Upload-Metadata
|
|
22
|
+
* PATCH <mountPath>/<id> → 204 + Upload-Offset
|
|
23
|
+
* DELETE <mountPath>/<id> → 204
|
|
24
|
+
* OPTIONS <mountPath> → 204 + Tus-* discovery
|
|
25
|
+
*
|
|
26
|
+
* Extensions implemented:
|
|
27
|
+
* - creation (§4) POST creates a new upload; Upload-Defer-Length
|
|
28
|
+
* supported per §4.3
|
|
29
|
+
* - creation-with-upload (§4.4) Content-Type application/offset+octet-stream
|
|
30
|
+
* on POST appends in the same call
|
|
31
|
+
* - expiration (§4.5) Upload-Expires header on every response;
|
|
32
|
+
* store.terminate() purges expired uploads
|
|
33
|
+
* - checksum (§3.5) Upload-Checksum: <algo> <base64> validated
|
|
34
|
+
* against received bytes; mismatch → 460
|
|
35
|
+
* - termination (§3.4) DELETE removes the upload
|
|
36
|
+
*
|
|
37
|
+
* Concatenation (§4.6) is intentionally not in v1 — operators that need
|
|
38
|
+
* parallel-chunk assembly compose it in their own store layer; re-open
|
|
39
|
+
* if an operator demonstrates a use case the store-level approach
|
|
40
|
+
* cannot satisfy.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
var nodeCrypto = require("crypto"); // for createHash() in checksum extension
|
|
44
|
+
var C = require("../constants");
|
|
45
|
+
var bCrypto = require("../crypto");
|
|
46
|
+
var lazyRequire = require("../lazy-require");
|
|
47
|
+
var safeAsync = require("../safe-async");
|
|
48
|
+
var safeBuffer = require("../safe-buffer");
|
|
49
|
+
var validateOpts = require("../validate-opts");
|
|
50
|
+
var { defineClass } = require("../framework-error");
|
|
51
|
+
|
|
52
|
+
// Observability metric prefix for the TUS middleware. The framework
|
|
53
|
+
// audit pipeline routes through `observability.safeEvent` (metrics +
|
|
54
|
+
// counters) for hot-path lifecycle signals, not `audit.safeEmit`,
|
|
55
|
+
// because PATCH chunks fire dozens of times per upload and the
|
|
56
|
+
// audit chain is reserved for security-relevant state transitions.
|
|
57
|
+
var TUS_ID_BYTES = C.BYTES.bytes(18); // 144 bits ≈ 24 base64url chars per upload id
|
|
58
|
+
|
|
59
|
+
// HTTP status codes used by TUS — hoisted to named constants so the
|
|
60
|
+
// raw-byte-literal detector doesn't fire on every status path.
|
|
61
|
+
var STATUS_OK = 200; // allow:raw-byte-literal — HTTP status
|
|
62
|
+
var STATUS_CREATED = 201; // allow:raw-byte-literal — HTTP status
|
|
63
|
+
var STATUS_NO_CONTENT = 204; // allow:raw-byte-literal — HTTP status
|
|
64
|
+
var STATUS_BAD_REQUEST = 400; // allow:raw-byte-literal — HTTP status
|
|
65
|
+
var STATUS_NOT_FOUND = 404; // allow:raw-byte-literal — HTTP status
|
|
66
|
+
var STATUS_METHOD_NOT_ALLOWED = 405; // allow:raw-byte-literal — HTTP status
|
|
67
|
+
var STATUS_CONFLICT = 409; // allow:raw-byte-literal — HTTP status
|
|
68
|
+
var STATUS_PRECONDITION_FAILED = 412; // allow:raw-byte-literal — HTTP status
|
|
69
|
+
var STATUS_PAYLOAD_TOO_LARGE = 413; // allow:raw-byte-literal — HTTP status
|
|
70
|
+
var STATUS_UNSUPPORTED_MEDIA = 415; // allow:raw-byte-literal — HTTP status
|
|
71
|
+
var STATUS_CHECKSUM_MISMATCH = 460; // allow:raw-byte-literal — TUS-specific status (§3.5)
|
|
72
|
+
var STATUS_INTERNAL_ERROR = 500; // allow:raw-byte-literal — HTTP status
|
|
73
|
+
|
|
74
|
+
var TusError = defineClass("TusError", { alwaysPermanent: true });
|
|
75
|
+
|
|
76
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
77
|
+
|
|
78
|
+
var TUS_VERSION = "1.0.0";
|
|
79
|
+
var SUPPORTED_VERSIONS = ["1.0.0"];
|
|
80
|
+
var DEFAULT_EXTENSIONS = [
|
|
81
|
+
"creation", "creation-with-upload", "expiration",
|
|
82
|
+
"checksum", "termination",
|
|
83
|
+
];
|
|
84
|
+
var DEFAULT_CHECKSUM_ALGORITHMS = ["sha3-512", "shake256"];
|
|
85
|
+
var KNOWN_CHECKSUM_ALGORITHMS = {
|
|
86
|
+
"sha3-512": "sha3-512",
|
|
87
|
+
"shake256": "shake256",
|
|
88
|
+
"sha-256": "sha256",
|
|
89
|
+
"sha-512": "sha512",
|
|
90
|
+
"sha3-256": "sha3-256",
|
|
91
|
+
};
|
|
92
|
+
var KNOWN_EXTENSIONS = {
|
|
93
|
+
"creation": true,
|
|
94
|
+
"creation-with-upload": true,
|
|
95
|
+
"expiration": true,
|
|
96
|
+
"checksum": true,
|
|
97
|
+
"termination": true,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function _b64uId() {
|
|
101
|
+
return bCrypto.generateBytes(TUS_ID_BYTES).toString("base64url");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _parseMetadata(headerValue) {
|
|
105
|
+
// RFC-style key-value list: `key1 base64val1,key2 base64val2`. Per
|
|
106
|
+
// tus.io 1.0.0 §3.2 keys are ASCII printable except space/comma; values
|
|
107
|
+
// are base64-encoded UTF-8 octet sequences.
|
|
108
|
+
if (typeof headerValue !== "string" || headerValue.length === 0) return null;
|
|
109
|
+
var pairs = headerValue.split(",");
|
|
110
|
+
var out = {};
|
|
111
|
+
for (var i = 0; i < pairs.length; i++) {
|
|
112
|
+
var raw = pairs[i].trim();
|
|
113
|
+
if (raw.length === 0) continue;
|
|
114
|
+
var sp = raw.indexOf(" ");
|
|
115
|
+
var key, val;
|
|
116
|
+
if (sp === -1) { key = raw; val = ""; }
|
|
117
|
+
else { key = raw.slice(0, sp); val = raw.slice(sp + 1); }
|
|
118
|
+
if (!/^[!-+\--.0-~]+$/.test(key)) return null; // printable, no space/comma
|
|
119
|
+
if (val.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(val)) return null;
|
|
120
|
+
var decoded = "";
|
|
121
|
+
if (val.length > 0) {
|
|
122
|
+
try { decoded = Buffer.from(val, "base64").toString("utf8"); }
|
|
123
|
+
catch (_e) { return null; }
|
|
124
|
+
}
|
|
125
|
+
out[key] = decoded;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _serializeMetadata(metaObj) {
|
|
131
|
+
if (!metaObj || typeof metaObj !== "object") return "";
|
|
132
|
+
var keys = Object.keys(metaObj);
|
|
133
|
+
var parts = [];
|
|
134
|
+
for (var i = 0; i < keys.length; i++) {
|
|
135
|
+
var k = keys[i];
|
|
136
|
+
var v = metaObj[k];
|
|
137
|
+
var encoded = (typeof v === "string" && v.length > 0)
|
|
138
|
+
? Buffer.from(v, "utf8").toString("base64")
|
|
139
|
+
: "";
|
|
140
|
+
parts.push(encoded.length > 0 ? (k + " " + encoded) : k);
|
|
141
|
+
}
|
|
142
|
+
return parts.join(",");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _parseChecksumHeader(headerValue, allowedSet) {
|
|
146
|
+
// tus.io 1.0.0 §3.5: `Upload-Checksum: <algo> <base64-digest>`.
|
|
147
|
+
if (typeof headerValue !== "string") return null;
|
|
148
|
+
var sp = headerValue.indexOf(" ");
|
|
149
|
+
if (sp === -1) return { error: "malformed" };
|
|
150
|
+
var algo = headerValue.slice(0, sp).trim().toLowerCase();
|
|
151
|
+
var digestB64 = headerValue.slice(sp + 1).trim();
|
|
152
|
+
if (algo.length === 0 || digestB64.length === 0) return { error: "malformed" };
|
|
153
|
+
if (!allowedSet[algo]) return { error: "algo-unsupported" };
|
|
154
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(digestB64)) return { error: "malformed" };
|
|
155
|
+
var nodeAlgo = KNOWN_CHECKSUM_ALGORITHMS[algo];
|
|
156
|
+
if (!nodeAlgo) return { error: "algo-unsupported" };
|
|
157
|
+
return { algo: algo, nodeAlgo: nodeAlgo, digestB64: digestB64 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function memoryStore(opts) {
|
|
161
|
+
opts = opts || {};
|
|
162
|
+
var maxSize = opts.maxSize;
|
|
163
|
+
if (maxSize !== undefined && (typeof maxSize !== "number" || !isFinite(maxSize) || maxSize <= 0)) {
|
|
164
|
+
throw new TusError("tus/bad-store-opts",
|
|
165
|
+
"tusUpload.memoryStore: maxSize must be a positive finite number");
|
|
166
|
+
}
|
|
167
|
+
var defaultExpirationMs = opts.defaultExpirationMs || C.TIME.hours(24);
|
|
168
|
+
|
|
169
|
+
var uploads = new Map(); // id -> { length, deferLength, metadata, buf, offset, expireAt, completed, terminated }
|
|
170
|
+
|
|
171
|
+
function create(meta) {
|
|
172
|
+
var id = _b64uId();
|
|
173
|
+
var now = Date.now();
|
|
174
|
+
var rec = {
|
|
175
|
+
id: id,
|
|
176
|
+
length: (typeof meta.length === "number" && isFinite(meta.length)) ? meta.length : null,
|
|
177
|
+
deferLength: meta.deferLength === true,
|
|
178
|
+
metadata: meta.metadata || {},
|
|
179
|
+
buf: Buffer.alloc(0),
|
|
180
|
+
offset: 0,
|
|
181
|
+
expireAt: now + (meta.expirationMs || defaultExpirationMs),
|
|
182
|
+
completed: false,
|
|
183
|
+
terminated: false,
|
|
184
|
+
hashState: null,
|
|
185
|
+
};
|
|
186
|
+
uploads.set(id, rec);
|
|
187
|
+
return Promise.resolve(rec);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function head(id) {
|
|
191
|
+
var rec = uploads.get(id);
|
|
192
|
+
if (!rec || rec.terminated) return Promise.resolve(null);
|
|
193
|
+
if (rec.expireAt && rec.expireAt < Date.now()) {
|
|
194
|
+
uploads.delete(id);
|
|
195
|
+
return Promise.resolve(null);
|
|
196
|
+
}
|
|
197
|
+
return Promise.resolve(rec);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function append(id, chunk, offset) {
|
|
201
|
+
var rec = uploads.get(id);
|
|
202
|
+
if (!rec || rec.terminated) return Promise.reject(new TusError("tus/upload-not-found", "upload " + id + " not found"));
|
|
203
|
+
if (offset !== rec.offset) {
|
|
204
|
+
return Promise.reject(new TusError("tus/offset-mismatch", "expected offset " + rec.offset + ", got " + offset));
|
|
205
|
+
}
|
|
206
|
+
if (rec.length !== null && rec.offset + chunk.length > rec.length) {
|
|
207
|
+
return Promise.reject(new TusError("tus/length-exceeded", "chunk would exceed declared Upload-Length"));
|
|
208
|
+
}
|
|
209
|
+
if (maxSize !== undefined && rec.offset + chunk.length > maxSize) {
|
|
210
|
+
return Promise.reject(new TusError("tus/length-exceeded", "chunk would exceed memoryStore maxSize"));
|
|
211
|
+
}
|
|
212
|
+
rec.buf = Buffer.concat([rec.buf, chunk]);
|
|
213
|
+
rec.offset += chunk.length;
|
|
214
|
+
if (rec.length !== null && rec.offset === rec.length) rec.completed = true;
|
|
215
|
+
return Promise.resolve(rec);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function setLength(id, length) {
|
|
219
|
+
var rec = uploads.get(id);
|
|
220
|
+
if (!rec) return Promise.reject(new TusError("tus/upload-not-found", "upload " + id + " not found"));
|
|
221
|
+
if (rec.length !== null) return Promise.reject(new TusError("tus/length-already-set", "Upload-Length already declared"));
|
|
222
|
+
rec.length = length;
|
|
223
|
+
rec.deferLength = false;
|
|
224
|
+
return Promise.resolve(rec);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function terminate(id) {
|
|
228
|
+
var rec = uploads.get(id);
|
|
229
|
+
if (!rec) return Promise.resolve(false);
|
|
230
|
+
rec.terminated = true;
|
|
231
|
+
uploads.delete(id);
|
|
232
|
+
return Promise.resolve(true);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function purgeExpired() {
|
|
236
|
+
var now = Date.now();
|
|
237
|
+
var removed = 0;
|
|
238
|
+
for (var entry of uploads) {
|
|
239
|
+
if (entry[1].expireAt && entry[1].expireAt < now) { uploads.delete(entry[0]); removed++; }
|
|
240
|
+
}
|
|
241
|
+
return Promise.resolve(removed);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getBuffer(id) {
|
|
245
|
+
var rec = uploads.get(id);
|
|
246
|
+
return Promise.resolve(rec ? rec.buf : null);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
name: "memory",
|
|
251
|
+
create: create,
|
|
252
|
+
head: head,
|
|
253
|
+
append: append,
|
|
254
|
+
setLength: setLength,
|
|
255
|
+
terminate: terminate,
|
|
256
|
+
purgeExpired: purgeExpired,
|
|
257
|
+
getBuffer: getBuffer,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _writeError(res, status, body) {
|
|
262
|
+
if (res.headersSent) return;
|
|
263
|
+
var bodyStr = body || "";
|
|
264
|
+
res.writeHead(status, {
|
|
265
|
+
"Tus-Resumable": TUS_VERSION,
|
|
266
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
267
|
+
"Content-Length": Buffer.byteLength(bodyStr),
|
|
268
|
+
});
|
|
269
|
+
res.end(bodyStr);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _emitTusBaseHeaders(res, extra) {
|
|
273
|
+
var headers = Object.assign({
|
|
274
|
+
"Tus-Resumable": TUS_VERSION,
|
|
275
|
+
}, extra || {});
|
|
276
|
+
return headers;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _readChunk(req, maxChunkSize) {
|
|
280
|
+
return new Promise(function (resolve, reject) {
|
|
281
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
282
|
+
maxBytes: maxChunkSize,
|
|
283
|
+
errorClass: TusError,
|
|
284
|
+
sizeCode: "tus/chunk-too-large",
|
|
285
|
+
sizeMessage: "PATCH body exceeded maxChunkSize",
|
|
286
|
+
});
|
|
287
|
+
req.on("data", function (c) {
|
|
288
|
+
try { collector.push(c); }
|
|
289
|
+
catch (e) { req.removeAllListeners("data"); reject(e); }
|
|
290
|
+
});
|
|
291
|
+
req.on("end", function () { resolve(collector.result()); });
|
|
292
|
+
req.on("error", function (e) { reject(e); });
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function create(opts) {
|
|
297
|
+
validateOpts.requireObject(opts, "middleware.tusUpload", TusError);
|
|
298
|
+
validateOpts(opts, [
|
|
299
|
+
"mountPath", "store", "maxSize", "maxChunkSize",
|
|
300
|
+
"expirationSec", "extensions", "checksumAlgorithms",
|
|
301
|
+
"onComplete", "onCreate", "onTerminate", "audit",
|
|
302
|
+
], "middleware.tusUpload");
|
|
303
|
+
|
|
304
|
+
var mountPath = opts.mountPath;
|
|
305
|
+
if (typeof mountPath !== "string" || mountPath.length === 0 || mountPath.charAt(0) !== "/") {
|
|
306
|
+
throw new TusError("tus/bad-mountpath",
|
|
307
|
+
"middleware.tusUpload: mountPath must be a non-empty path starting with '/'");
|
|
308
|
+
}
|
|
309
|
+
if (mountPath.length > 1 && mountPath.charAt(mountPath.length - 1) === "/") {
|
|
310
|
+
mountPath = mountPath.slice(0, -1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
var store = opts.store;
|
|
314
|
+
if (!store || typeof store.create !== "function" || typeof store.head !== "function" ||
|
|
315
|
+
typeof store.append !== "function" || typeof store.terminate !== "function") {
|
|
316
|
+
throw new TusError("tus/bad-store",
|
|
317
|
+
"middleware.tusUpload: store must implement { create, head, append, terminate }");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
var maxSize = opts.maxSize;
|
|
321
|
+
if (maxSize !== undefined && (typeof maxSize !== "number" || !isFinite(maxSize) || maxSize <= 0)) {
|
|
322
|
+
throw new TusError("tus/bad-opts", "middleware.tusUpload: maxSize must be a positive finite number");
|
|
323
|
+
}
|
|
324
|
+
var maxChunkSize = opts.maxChunkSize;
|
|
325
|
+
if (maxChunkSize === undefined) maxChunkSize = C.BYTES.mib(64);
|
|
326
|
+
if (typeof maxChunkSize !== "number" || !isFinite(maxChunkSize) || maxChunkSize <= 0) {
|
|
327
|
+
throw new TusError("tus/bad-opts", "middleware.tusUpload: maxChunkSize must be a positive finite number");
|
|
328
|
+
}
|
|
329
|
+
var expirationSec = opts.expirationSec;
|
|
330
|
+
if (expirationSec !== undefined && (typeof expirationSec !== "number" || !isFinite(expirationSec) || expirationSec <= 0)) {
|
|
331
|
+
throw new TusError("tus/bad-opts", "middleware.tusUpload: expirationSec must be a positive finite number");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
var extensions = Array.isArray(opts.extensions) ? opts.extensions.slice() : DEFAULT_EXTENSIONS.slice();
|
|
335
|
+
for (var i = 0; i < extensions.length; i++) {
|
|
336
|
+
if (!KNOWN_EXTENSIONS[extensions[i]]) {
|
|
337
|
+
throw new TusError("tus/bad-opts",
|
|
338
|
+
"middleware.tusUpload: unknown extension '" + extensions[i] + "'");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
var hasCreation = extensions.indexOf("creation") !== -1;
|
|
342
|
+
var hasCreationWithBody = extensions.indexOf("creation-with-upload") !== -1;
|
|
343
|
+
var hasExpiration = extensions.indexOf("expiration") !== -1;
|
|
344
|
+
var hasChecksum = extensions.indexOf("checksum") !== -1;
|
|
345
|
+
var hasTermination = extensions.indexOf("termination") !== -1;
|
|
346
|
+
|
|
347
|
+
var checksumAlgorithms = Array.isArray(opts.checksumAlgorithms)
|
|
348
|
+
? opts.checksumAlgorithms.slice()
|
|
349
|
+
: DEFAULT_CHECKSUM_ALGORITHMS.slice();
|
|
350
|
+
var checksumAlgorithmSet = {};
|
|
351
|
+
for (var j = 0; j < checksumAlgorithms.length; j++) {
|
|
352
|
+
var algo = checksumAlgorithms[j];
|
|
353
|
+
if (!KNOWN_CHECKSUM_ALGORITHMS[algo]) {
|
|
354
|
+
throw new TusError("tus/bad-opts",
|
|
355
|
+
"middleware.tusUpload: unknown checksum algorithm '" + algo + "'");
|
|
356
|
+
}
|
|
357
|
+
checksumAlgorithmSet[algo] = true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
validateOpts.optionalFunction(opts.onComplete, "middleware.tusUpload: onComplete", TusError, "tus/bad-opts");
|
|
361
|
+
validateOpts.optionalFunction(opts.onCreate, "middleware.tusUpload: onCreate", TusError, "tus/bad-opts");
|
|
362
|
+
validateOpts.optionalFunction(opts.onTerminate, "middleware.tusUpload: onTerminate", TusError, "tus/bad-opts");
|
|
363
|
+
|
|
364
|
+
var auditOn = opts.audit !== false;
|
|
365
|
+
|
|
366
|
+
if (hasExpiration && typeof store.purgeExpired === "function") {
|
|
367
|
+
safeAsync.repeating(function () {
|
|
368
|
+
store.purgeExpired().catch(function () { /* drop-silent — sweep best-effort */ });
|
|
369
|
+
}, C.TIME.minutes(5), { name: "tus-upload-sweep" });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _expirationHeader(rec) {
|
|
373
|
+
if (!hasExpiration || !rec || !rec.expireAt) return null;
|
|
374
|
+
return new Date(rec.expireAt).toUTCString();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function _emitMetric(verb, _outcome, _metadata) {
|
|
378
|
+
if (!auditOn) return;
|
|
379
|
+
try { observability().safeEvent("middleware.tusUpload." + verb, 1, {}); }
|
|
380
|
+
catch (_e) { /* drop-silent — observability sink */ }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function _handleOptions(req, res) {
|
|
384
|
+
var headers = _emitTusBaseHeaders(res, {
|
|
385
|
+
"Tus-Version": SUPPORTED_VERSIONS.join(","),
|
|
386
|
+
"Tus-Extension": extensions.join(","),
|
|
387
|
+
});
|
|
388
|
+
if (maxSize !== undefined) headers["Tus-Max-Size"] = String(maxSize);
|
|
389
|
+
if (hasChecksum) headers["Tus-Checksum-Algorithm"] = checksumAlgorithms.join(",");
|
|
390
|
+
res.writeHead(STATUS_NO_CONTENT, headers);
|
|
391
|
+
res.end();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function _handleCreate(req, res) {
|
|
395
|
+
if (!hasCreation) return _writeError(res, STATUS_METHOD_NOT_ALLOWED, "creation extension not enabled");
|
|
396
|
+
var lengthHdr = req.headers["upload-length"];
|
|
397
|
+
var deferHdr = req.headers["upload-defer-length"];
|
|
398
|
+
var metadataHdr = req.headers["upload-metadata"];
|
|
399
|
+
|
|
400
|
+
var uploadLength = null;
|
|
401
|
+
var deferLength = false;
|
|
402
|
+
if (lengthHdr !== undefined) {
|
|
403
|
+
uploadLength = parseInt(lengthHdr, 10);
|
|
404
|
+
if (!isFinite(uploadLength) || uploadLength < 0 || String(uploadLength) !== String(lengthHdr).trim()) {
|
|
405
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
|
|
406
|
+
}
|
|
407
|
+
if (maxSize !== undefined && uploadLength > maxSize) {
|
|
408
|
+
return _writeError(res, STATUS_PAYLOAD_TOO_LARGE, "Upload-Length exceeds Tus-Max-Size");
|
|
409
|
+
}
|
|
410
|
+
} else if (String(deferHdr).trim() === "1") {
|
|
411
|
+
deferLength = true;
|
|
412
|
+
} else {
|
|
413
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length or Upload-Defer-Length: 1 required");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
var metadata = null;
|
|
417
|
+
if (metadataHdr !== undefined) {
|
|
418
|
+
metadata = _parseMetadata(metadataHdr);
|
|
419
|
+
if (metadata === null) return _writeError(res, STATUS_BAD_REQUEST, "malformed Upload-Metadata");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
var rec;
|
|
423
|
+
try {
|
|
424
|
+
rec = await store.create({
|
|
425
|
+
length: uploadLength,
|
|
426
|
+
deferLength: deferLength,
|
|
427
|
+
metadata: metadata || {},
|
|
428
|
+
expirationMs: expirationSec ? C.TIME.seconds(expirationSec) : undefined,
|
|
429
|
+
});
|
|
430
|
+
} catch (e) {
|
|
431
|
+
_emitMetric("create.fail");
|
|
432
|
+
return _writeError(res, STATUS_INTERNAL_ERROR, (e && e.message) || "store create failed");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (typeof opts.onCreate === "function") {
|
|
436
|
+
try { await opts.onCreate(rec.id, { length: uploadLength, metadata: metadata }); }
|
|
437
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
var location = mountPath + "/" + rec.id;
|
|
441
|
+
var headers = _emitTusBaseHeaders(res, { "Location": location });
|
|
442
|
+
var expHdr = _expirationHeader(rec);
|
|
443
|
+
if (expHdr) headers["Upload-Expires"] = expHdr;
|
|
444
|
+
|
|
445
|
+
// creation-with-upload: append the body in the same request when
|
|
446
|
+
// Content-Type is application/offset+octet-stream.
|
|
447
|
+
var contentType = req.headers["content-type"];
|
|
448
|
+
if (hasCreationWithBody && contentType === "application/offset+octet-stream") {
|
|
449
|
+
var chunk;
|
|
450
|
+
try { chunk = await _readChunk(req, maxChunkSize); }
|
|
451
|
+
catch (e) { return _writeError(res, e.code === "tus/chunk-too-large" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message); }
|
|
452
|
+
try {
|
|
453
|
+
rec = await store.append(rec.id, chunk, 0);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
return _writeError(res, e.code === "tus/length-exceeded" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message);
|
|
456
|
+
}
|
|
457
|
+
headers["Upload-Offset"] = String(rec.offset);
|
|
458
|
+
if (rec.completed && typeof opts.onComplete === "function") {
|
|
459
|
+
try { await opts.onComplete(rec.id, { metadata: rec.metadata, store: store }); }
|
|
460
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
res.writeHead(STATUS_CREATED, headers);
|
|
465
|
+
res.end();
|
|
466
|
+
_emitMetric("create.ok");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function _handleHead(req, res, id) {
|
|
470
|
+
var rec;
|
|
471
|
+
try { rec = await store.head(id); }
|
|
472
|
+
catch (_e) { rec = null; }
|
|
473
|
+
if (!rec) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
|
|
474
|
+
var headers = _emitTusBaseHeaders(res, {
|
|
475
|
+
"Upload-Offset": String(rec.offset),
|
|
476
|
+
"Cache-Control": "no-store",
|
|
477
|
+
});
|
|
478
|
+
if (rec.length !== null) headers["Upload-Length"] = String(rec.length);
|
|
479
|
+
else if (rec.deferLength) headers["Upload-Defer-Length"] = "1";
|
|
480
|
+
if (rec.metadata && Object.keys(rec.metadata).length > 0) {
|
|
481
|
+
headers["Upload-Metadata"] = _serializeMetadata(rec.metadata);
|
|
482
|
+
}
|
|
483
|
+
var expHdr = _expirationHeader(rec);
|
|
484
|
+
if (expHdr) headers["Upload-Expires"] = expHdr;
|
|
485
|
+
res.writeHead(STATUS_OK, headers);
|
|
486
|
+
res.end();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function _handlePatch(req, res, id) {
|
|
490
|
+
var contentType = req.headers["content-type"];
|
|
491
|
+
if (contentType !== "application/offset+octet-stream") {
|
|
492
|
+
return _writeError(res, STATUS_UNSUPPORTED_MEDIA, "Content-Type must be application/offset+octet-stream");
|
|
493
|
+
}
|
|
494
|
+
var offsetHdr = req.headers["upload-offset"];
|
|
495
|
+
if (offsetHdr === undefined) return _writeError(res, STATUS_BAD_REQUEST, "Upload-Offset required");
|
|
496
|
+
var offset = parseInt(offsetHdr, 10);
|
|
497
|
+
if (!isFinite(offset) || offset < 0 || String(offset) !== String(offsetHdr).trim()) {
|
|
498
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Offset must be a non-negative integer");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
var rec;
|
|
502
|
+
try { rec = await store.head(id); }
|
|
503
|
+
catch (_e) { rec = null; }
|
|
504
|
+
if (!rec) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
|
|
505
|
+
|
|
506
|
+
if (rec.length === null && req.headers["upload-length"] !== undefined) {
|
|
507
|
+
// Upload-Defer-Length finalization (§4.3) — declare length on first PATCH
|
|
508
|
+
var declared = parseInt(req.headers["upload-length"], 10);
|
|
509
|
+
if (!isFinite(declared) || declared < 0) {
|
|
510
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
|
|
511
|
+
}
|
|
512
|
+
if (maxSize !== undefined && declared > maxSize) {
|
|
513
|
+
return _writeError(res, STATUS_PAYLOAD_TOO_LARGE, "Upload-Length exceeds Tus-Max-Size");
|
|
514
|
+
}
|
|
515
|
+
try { rec = await store.setLength(id, declared); }
|
|
516
|
+
catch (e) { return _writeError(res, STATUS_CONFLICT, e.message); }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (offset !== rec.offset) {
|
|
520
|
+
return _writeError(res, STATUS_CONFLICT, "Upload-Offset mismatch (expected " + rec.offset + ")");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
var checksum = null;
|
|
524
|
+
if (req.headers["upload-checksum"] !== undefined) {
|
|
525
|
+
if (!hasChecksum) return _writeError(res, STATUS_BAD_REQUEST, "checksum extension not enabled");
|
|
526
|
+
checksum = _parseChecksumHeader(req.headers["upload-checksum"], checksumAlgorithmSet);
|
|
527
|
+
if (!checksum || checksum.error) {
|
|
528
|
+
if (checksum && checksum.error === "algo-unsupported") {
|
|
529
|
+
return _writeError(res, STATUS_BAD_REQUEST, "checksum algorithm unsupported");
|
|
530
|
+
}
|
|
531
|
+
return _writeError(res, STATUS_BAD_REQUEST, "malformed Upload-Checksum");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
var chunk;
|
|
536
|
+
try { chunk = await _readChunk(req, maxChunkSize); }
|
|
537
|
+
catch (e) { return _writeError(res, e.code === "tus/chunk-too-large" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message); }
|
|
538
|
+
|
|
539
|
+
if (checksum) {
|
|
540
|
+
var hasher = nodeCrypto.createHash(checksum.nodeAlgo);
|
|
541
|
+
hasher.update(chunk);
|
|
542
|
+
var digestB64 = hasher.digest("base64");
|
|
543
|
+
if (digestB64 !== checksum.digestB64) {
|
|
544
|
+
return _writeError(res, STATUS_CHECKSUM_MISMATCH, "Upload-Checksum mismatch");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
try { rec = await store.append(id, chunk, offset); }
|
|
549
|
+
catch (e) {
|
|
550
|
+
var sc = STATUS_INTERNAL_ERROR;
|
|
551
|
+
if (e.code === "tus/offset-mismatch") sc = STATUS_CONFLICT;
|
|
552
|
+
else if (e.code === "tus/length-exceeded") sc = STATUS_PAYLOAD_TOO_LARGE;
|
|
553
|
+
else if (e.code === "tus/upload-not-found") sc = STATUS_NOT_FOUND;
|
|
554
|
+
return _writeError(res, sc, e.message);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
var headers = _emitTusBaseHeaders(res, { "Upload-Offset": String(rec.offset) });
|
|
558
|
+
var expHdr = _expirationHeader(rec);
|
|
559
|
+
if (expHdr) headers["Upload-Expires"] = expHdr;
|
|
560
|
+
|
|
561
|
+
res.writeHead(STATUS_NO_CONTENT, headers);
|
|
562
|
+
res.end();
|
|
563
|
+
|
|
564
|
+
if (rec.completed && typeof opts.onComplete === "function") {
|
|
565
|
+
try { await opts.onComplete(id, { metadata: rec.metadata, store: store }); }
|
|
566
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
567
|
+
_emitMetric("complete.ok");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function _handleDelete(req, res, id) {
|
|
572
|
+
if (!hasTermination) return _writeError(res, STATUS_METHOD_NOT_ALLOWED, "termination extension not enabled");
|
|
573
|
+
var existed;
|
|
574
|
+
try { existed = await store.terminate(id); }
|
|
575
|
+
catch (_e) { existed = false; }
|
|
576
|
+
if (!existed) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
|
|
577
|
+
if (typeof opts.onTerminate === "function") {
|
|
578
|
+
try { await opts.onTerminate(id); }
|
|
579
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
580
|
+
}
|
|
581
|
+
res.writeHead(STATUS_NO_CONTENT, _emitTusBaseHeaders(res, {}));
|
|
582
|
+
res.end();
|
|
583
|
+
_emitMetric("terminate.ok");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return async function tusUploadMiddleware(req, res, next) {
|
|
587
|
+
var url = req.url || "/";
|
|
588
|
+
var qIdx = url.indexOf("?");
|
|
589
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
590
|
+
|
|
591
|
+
var isCollection = (path === mountPath);
|
|
592
|
+
var isResource = false;
|
|
593
|
+
var resourceId = null;
|
|
594
|
+
if (path.indexOf(mountPath + "/") === 0) {
|
|
595
|
+
resourceId = path.slice(mountPath.length + 1);
|
|
596
|
+
// No further sub-paths allowed — TUS resources are flat.
|
|
597
|
+
if (resourceId.indexOf("/") === -1 && /^[A-Za-z0-9_-]{1,128}$/.test(resourceId)) {
|
|
598
|
+
isResource = true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (!isCollection && !isResource) return next();
|
|
602
|
+
|
|
603
|
+
// Tus-Resumable header gate (§2.2). OPTIONS is exempt; all other
|
|
604
|
+
// verbs must declare a supported version.
|
|
605
|
+
var method = (req.method || "").toUpperCase();
|
|
606
|
+
if (method !== "OPTIONS") {
|
|
607
|
+
var version = req.headers["tus-resumable"];
|
|
608
|
+
if (version === undefined) {
|
|
609
|
+
return _writeError(res, STATUS_PRECONDITION_FAILED, "Tus-Resumable header required");
|
|
610
|
+
}
|
|
611
|
+
if (SUPPORTED_VERSIONS.indexOf(version) === -1) {
|
|
612
|
+
var hdrs = _emitTusBaseHeaders(res, { "Tus-Version": SUPPORTED_VERSIONS.join(",") });
|
|
613
|
+
res.writeHead(STATUS_PRECONDITION_FAILED, hdrs);
|
|
614
|
+
res.end("Tus-Resumable version unsupported");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
if (method === "OPTIONS") return await _handleOptions(req, res);
|
|
621
|
+
if (isCollection && method === "POST") return await _handleCreate(req, res);
|
|
622
|
+
if (isResource && method === "HEAD") return await _handleHead(req, res, resourceId);
|
|
623
|
+
if (isResource && method === "PATCH") return await _handlePatch(req, res, resourceId);
|
|
624
|
+
if (isResource && method === "DELETE") return await _handleDelete(req, res, resourceId);
|
|
625
|
+
} catch (e) {
|
|
626
|
+
_emitMetric("error.fail");
|
|
627
|
+
return _writeError(res, STATUS_INTERNAL_ERROR, (e && e.message) || "internal error");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
var allow = isCollection ? "OPTIONS, POST" : "OPTIONS, HEAD, PATCH" + (hasTermination ? ", DELETE" : "");
|
|
631
|
+
res.writeHead(STATUS_METHOD_NOT_ALLOWED, _emitTusBaseHeaders(res, {
|
|
632
|
+
"Allow": allow,
|
|
633
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
634
|
+
"Content-Length": "0",
|
|
635
|
+
}));
|
|
636
|
+
res.end();
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function close(middleware) {
|
|
641
|
+
// Reserved for future store-close hook; the sweep timer is the only
|
|
642
|
+
// resource currently bound, and it lives inside the middleware closure.
|
|
643
|
+
if (middleware && typeof middleware.close === "function") middleware.close();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
module.exports = {
|
|
647
|
+
create: create,
|
|
648
|
+
memoryStore: memoryStore,
|
|
649
|
+
close: close,
|
|
650
|
+
TusError: TusError,
|
|
651
|
+
TUS_VERSION: TUS_VERSION,
|
|
652
|
+
KNOWN_EXTENSIONS: KNOWN_EXTENSIONS,
|
|
653
|
+
KNOWN_CHECKSUM_ALGORITHMS: KNOWN_CHECKSUM_ALGORITHMS,
|
|
654
|
+
};
|