@orkestrel/middleware 0.0.5 → 0.0.7
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/dist/src/core/index.cjs +81 -66
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +40 -23
- package/dist/src/core/index.d.ts +40 -23
- package/dist/src/core/index.js +81 -67
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +344 -256
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +51 -0
- package/dist/src/server/index.d.ts +51 -0
- package/dist/src/server/index.js +342 -257
- package/dist/src/server/index.js.map +1 -1
- package/package.json +25 -23
package/dist/src/server/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
2
|
import { chmod, copyFile, mkdtemp, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
3
|
import { extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
5
|
-
import {
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { deflate, gzip } from "node:zlib";
|
|
6
6
|
import { isFiniteNumber, isFunction, isRecord, isString } from "@orkestrel/contract";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
7
9
|
import { HTTPError, isDangerousKey, matchesETag, parseRange } from "@orkestrel/server";
|
|
8
10
|
import { DEFAULT_COMPRESSION_THRESHOLD, compressResponse } from "../core/index.js";
|
|
9
|
-
import { deflate, gzip } from "node:zlib";
|
|
10
|
-
import { promisify } from "node:util";
|
|
11
11
|
//#region src/server/constants.ts
|
|
12
12
|
/** The HTTP status `createMultipart` renders for each {@link MultipartReason}. */
|
|
13
13
|
var MULTIPART_REASON_STATUS = Object.freeze({
|
|
@@ -114,7 +114,7 @@ var MultipartError = class extends Error {
|
|
|
114
114
|
super(message);
|
|
115
115
|
this.status = MULTIPART_REASON_STATUS[reason];
|
|
116
116
|
this.reason = reason;
|
|
117
|
-
this.context = context;
|
|
117
|
+
if (context !== void 0) this.context = context;
|
|
118
118
|
Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
|
|
119
119
|
}
|
|
120
120
|
};
|
|
@@ -150,6 +150,210 @@ function isMultipartError(value) {
|
|
|
150
150
|
return true;
|
|
151
151
|
}
|
|
152
152
|
//#endregion
|
|
153
|
+
//#region src/server/MultipartParser.ts
|
|
154
|
+
var MultipartParser = class MultipartParser {
|
|
155
|
+
static #defaultDirectory;
|
|
156
|
+
#reader;
|
|
157
|
+
#signal;
|
|
158
|
+
#abort;
|
|
159
|
+
#boundary;
|
|
160
|
+
#limits;
|
|
161
|
+
#allowed;
|
|
162
|
+
#directory;
|
|
163
|
+
#staged = [];
|
|
164
|
+
#files = Object.create(null);
|
|
165
|
+
#fields = Object.create(null);
|
|
166
|
+
#buffer = Buffer.alloc(0);
|
|
167
|
+
#ended = false;
|
|
168
|
+
#fileCount = 0;
|
|
169
|
+
#fieldCount = 0;
|
|
170
|
+
#totalBytes = 0;
|
|
171
|
+
constructor(stream, signal, boundary, limits, allowed, directory) {
|
|
172
|
+
this.#reader = stream.getReader();
|
|
173
|
+
this.#signal = signal;
|
|
174
|
+
this.#abort = this.#wakeReader.bind(this);
|
|
175
|
+
this.#boundary = boundary;
|
|
176
|
+
this.#limits = limits;
|
|
177
|
+
this.#allowed = allowed;
|
|
178
|
+
this.#directory = directory;
|
|
179
|
+
}
|
|
180
|
+
static directory() {
|
|
181
|
+
if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
|
|
182
|
+
return MultipartParser.#defaultDirectory;
|
|
183
|
+
}
|
|
184
|
+
async parse() {
|
|
185
|
+
this.#signal.addEventListener("abort", this.#abort, { once: true });
|
|
186
|
+
try {
|
|
187
|
+
const openMarker = Buffer.from(`--${this.#boundary}`);
|
|
188
|
+
let preambleScanned = 0;
|
|
189
|
+
let index = this.#buffer.indexOf(openMarker);
|
|
190
|
+
while (index === -1) {
|
|
191
|
+
const carry = openMarker.length - 1;
|
|
192
|
+
if (this.#buffer.length > carry) {
|
|
193
|
+
const drop = this.#buffer.length - carry;
|
|
194
|
+
preambleScanned += drop;
|
|
195
|
+
if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
196
|
+
this.#buffer = this.#buffer.subarray(drop);
|
|
197
|
+
}
|
|
198
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
|
|
199
|
+
index = this.#buffer.indexOf(openMarker);
|
|
200
|
+
}
|
|
201
|
+
if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
202
|
+
this.#buffer = this.#buffer.subarray(index + openMarker.length);
|
|
203
|
+
for (;;) {
|
|
204
|
+
while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
205
|
+
if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
|
|
206
|
+
if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
|
|
207
|
+
this.#buffer = this.#buffer.subarray(2);
|
|
208
|
+
let headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
209
|
+
while (headerEnd === -1) {
|
|
210
|
+
if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
211
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
|
|
212
|
+
headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
213
|
+
}
|
|
214
|
+
if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
215
|
+
const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
|
|
216
|
+
this.#buffer = this.#buffer.subarray(headerEnd + 4);
|
|
217
|
+
const { name, filename, contentType } = parsePartHeaders(headerBlock);
|
|
218
|
+
if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
|
|
219
|
+
const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
|
|
220
|
+
if (filename !== void 0) {
|
|
221
|
+
if (filename !== "") {
|
|
222
|
+
this.#fileCount += 1;
|
|
223
|
+
if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
224
|
+
}
|
|
225
|
+
const path = join(this.#directory, randomUUID());
|
|
226
|
+
this.#staged.push(path);
|
|
227
|
+
const handle = await open(path, "w", 384);
|
|
228
|
+
let size = 0;
|
|
229
|
+
let head = Buffer.alloc(0);
|
|
230
|
+
try {
|
|
231
|
+
for (;;) {
|
|
232
|
+
const boundaryIndex = this.#buffer.indexOf(partDelimiter);
|
|
233
|
+
if (boundaryIndex === -1) {
|
|
234
|
+
const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
|
|
235
|
+
if (safeLength > 0) {
|
|
236
|
+
const chunk = this.#buffer.subarray(0, safeLength);
|
|
237
|
+
size += chunk.length;
|
|
238
|
+
if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
239
|
+
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
240
|
+
await handle.write(chunk);
|
|
241
|
+
this.#buffer = this.#buffer.subarray(safeLength);
|
|
242
|
+
}
|
|
243
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart file part");
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const chunk = this.#buffer.subarray(0, boundaryIndex);
|
|
247
|
+
size += chunk.length;
|
|
248
|
+
if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
249
|
+
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
250
|
+
await handle.write(chunk);
|
|
251
|
+
this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
await handle.close();
|
|
256
|
+
}
|
|
257
|
+
if (filename === "" && size === 0) {
|
|
258
|
+
await unlink(path);
|
|
259
|
+
this.#staged.splice(this.#staged.indexOf(path), 1);
|
|
260
|
+
} else {
|
|
261
|
+
if (filename === "") {
|
|
262
|
+
this.#fileCount += 1;
|
|
263
|
+
if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
264
|
+
}
|
|
265
|
+
const detected = detectMIME(head);
|
|
266
|
+
const declared = contentType ?? "application/octet-stream";
|
|
267
|
+
const validated = detected !== void 0 && detected === declared;
|
|
268
|
+
if (this.#allowed !== void 0) {
|
|
269
|
+
if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
|
|
270
|
+
}
|
|
271
|
+
if (isDangerousKey(name)) {
|
|
272
|
+
await unlink(path);
|
|
273
|
+
this.#staged.splice(this.#staged.indexOf(path), 1);
|
|
274
|
+
} else {
|
|
275
|
+
const record = createUploadedFile({
|
|
276
|
+
field: name,
|
|
277
|
+
name: filename,
|
|
278
|
+
size,
|
|
279
|
+
mime: detected ?? declared,
|
|
280
|
+
validated,
|
|
281
|
+
status: "staged",
|
|
282
|
+
path
|
|
283
|
+
});
|
|
284
|
+
const existing = this.#files[name];
|
|
285
|
+
if (existing === void 0) this.#files[name] = [record];
|
|
286
|
+
else existing.push(record);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
this.#fieldCount += 1;
|
|
291
|
+
if (this.#fieldCount > this.#limits.fields) throw new MultipartError("limit", "too many multipart fields");
|
|
292
|
+
let value = Buffer.alloc(0);
|
|
293
|
+
for (;;) {
|
|
294
|
+
const boundaryIndex = this.#buffer.indexOf(partDelimiter);
|
|
295
|
+
if (boundaryIndex === -1) {
|
|
296
|
+
const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
|
|
297
|
+
if (safeLength > 0) {
|
|
298
|
+
value = Buffer.concat([value, this.#buffer.subarray(0, safeLength)]);
|
|
299
|
+
if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
300
|
+
this.#buffer = this.#buffer.subarray(safeLength);
|
|
301
|
+
}
|
|
302
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart field part");
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
value = Buffer.concat([value, this.#buffer.subarray(0, boundaryIndex)]);
|
|
306
|
+
if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
307
|
+
this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
if (!isDangerousKey(name)) this.#fields[name] = value.toString("utf8");
|
|
311
|
+
}
|
|
312
|
+
while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
313
|
+
this.#buffer = this.#buffer.subarray(openMarker.length);
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
await this.#cleanup();
|
|
317
|
+
await this.#reader.cancel().catch(() => {});
|
|
318
|
+
throw error;
|
|
319
|
+
} finally {
|
|
320
|
+
this.#signal.removeEventListener("abort", this.#abort);
|
|
321
|
+
if (!this.#ended) await this.#reader.cancel().catch(() => {});
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
files: Object.freeze(this.#files),
|
|
325
|
+
fields: Object.freeze(this.#fields)
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
static async #createDirectory() {
|
|
329
|
+
const path = await mkdtemp(join(tmpdir(), "orkestrel-multipart-"));
|
|
330
|
+
await chmod(path, 448);
|
|
331
|
+
return path;
|
|
332
|
+
}
|
|
333
|
+
async #cleanup() {
|
|
334
|
+
for (const path of this.#staged) try {
|
|
335
|
+
await unlink(path);
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
async #pull() {
|
|
339
|
+
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
340
|
+
if (this.#ended) return false;
|
|
341
|
+
const { done, value } = await this.#reader.read();
|
|
342
|
+
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
343
|
+
if (done) {
|
|
344
|
+
this.#ended = true;
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
this.#totalBytes += value.byteLength;
|
|
348
|
+
if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
|
|
349
|
+
this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
#wakeReader() {
|
|
353
|
+
this.#reader.cancel().catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
//#endregion
|
|
153
357
|
//#region src/server/helpers.ts
|
|
154
358
|
/**
|
|
155
359
|
* Whether `pathname` is `prefix` itself or lies under it on a SEGMENT
|
|
@@ -173,6 +377,31 @@ function isUnderPath(pathname, prefix) {
|
|
|
173
377
|
return pathname.startsWith(boundary);
|
|
174
378
|
}
|
|
175
379
|
/**
|
|
380
|
+
* Resolve the fixed SPA shell path when a static-file miss is eligible for
|
|
381
|
+
* fallback.
|
|
382
|
+
*
|
|
383
|
+
* @param root - The configured static root
|
|
384
|
+
* @param index - The configured shell filename
|
|
385
|
+
* @param exclude - The URL prefix excluded from fallback
|
|
386
|
+
* @param method - The request method
|
|
387
|
+
* @param pathname - The request pathname
|
|
388
|
+
* @param accept - The request's `Accept` header value
|
|
389
|
+
* @returns The fixed shell path, or `undefined` when fallback is ineligible
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```ts
|
|
393
|
+
* resolveStaticFallbackPath('/srv/public', 'index.html', '/api', 'GET', '/dashboard', 'text/html')
|
|
394
|
+
* // '/srv/public/index.html'
|
|
395
|
+
* ```
|
|
396
|
+
*/
|
|
397
|
+
function resolveStaticFallbackPath(root, index, exclude, method, pathname, accept) {
|
|
398
|
+
if (method !== "GET") return void 0;
|
|
399
|
+
if (extname(pathname) !== "") return void 0;
|
|
400
|
+
if (!accept.includes("text/html") && !accept.includes("*/*")) return void 0;
|
|
401
|
+
if (isUnderPath(pathname, exclude)) return void 0;
|
|
402
|
+
return join(root, index);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
176
405
|
* Whether `child` is `parent` itself or lies inside it on-disk — the
|
|
177
406
|
* FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
|
|
178
407
|
* output (never to a URL pathname — that is {@link isUnderPath}'s job).
|
|
@@ -235,7 +464,8 @@ function resolveStaticPath(root, prefix, pathname) {
|
|
|
235
464
|
return;
|
|
236
465
|
}
|
|
237
466
|
if (decoded.includes("\0")) return void 0;
|
|
238
|
-
const
|
|
467
|
+
const stripped = decoded.replace(/^[/\\]+/, "");
|
|
468
|
+
const normalized = normalize(stripped);
|
|
239
469
|
const segments = normalized.split(/[/\\]+/).filter((segment) => segment.length > 0);
|
|
240
470
|
for (const segment of segments) if (isReservedDeviceName(segment)) return void 0;
|
|
241
471
|
const resolved = resolve(root, normalized);
|
|
@@ -312,6 +542,23 @@ function computeFileETag(size, mtimeMs) {
|
|
|
312
542
|
return `W/"${size}-${Math.floor(mtimeMs)}"`;
|
|
313
543
|
}
|
|
314
544
|
/**
|
|
545
|
+
* Compress response bytes with Node's guaranteed zlib gzip/deflate codecs.
|
|
546
|
+
*
|
|
547
|
+
* @param bytes - The uncompressed response bytes
|
|
548
|
+
* @param encoding - The negotiated actionable coding
|
|
549
|
+
* @returns The compressed bytes
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* ```ts
|
|
553
|
+
* const bytes = new TextEncoder().encode('compress me')
|
|
554
|
+
* const compressed = await compressNodeBytes(bytes, 'deflate')
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
async function compressNodeBytes(bytes, encoding) {
|
|
558
|
+
const compressed = encoding === "gzip" ? await promisify(gzip)(bytes) : await promisify(deflate)(bytes);
|
|
559
|
+
return Uint8Array.from(compressed);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
315
562
|
* Sniff a MIME type from a file's leading bytes against a small magic-byte
|
|
316
563
|
* table (jpeg, png, gif87a/89a, webp, pdf, zip) — the SNIFF-AUTHORITATIVE
|
|
317
564
|
* signal `createMultipart`'s type validation rests on, never the declared
|
|
@@ -326,17 +573,12 @@ function computeFileETag(size, mtimeMs) {
|
|
|
326
573
|
* ```
|
|
327
574
|
*/
|
|
328
575
|
function detectMIME(head) {
|
|
329
|
-
|
|
330
|
-
if (head.length < offset + signature.length) return false;
|
|
331
|
-
for (let index = 0; index < signature.length; index += 1) if (head[offset + index] !== signature[index]) return false;
|
|
332
|
-
return true;
|
|
333
|
-
}
|
|
334
|
-
if (matches([
|
|
576
|
+
if (matchesBytes(head, [
|
|
335
577
|
255,
|
|
336
578
|
216,
|
|
337
579
|
255
|
|
338
580
|
])) return "image/jpeg";
|
|
339
|
-
if (
|
|
581
|
+
if (matchesBytes(head, [
|
|
340
582
|
137,
|
|
341
583
|
80,
|
|
342
584
|
78,
|
|
@@ -346,7 +588,7 @@ function detectMIME(head) {
|
|
|
346
588
|
26,
|
|
347
589
|
10
|
|
348
590
|
])) return "image/png";
|
|
349
|
-
if (
|
|
591
|
+
if (matchesBytes(head, [
|
|
350
592
|
71,
|
|
351
593
|
73,
|
|
352
594
|
70,
|
|
@@ -354,7 +596,7 @@ function detectMIME(head) {
|
|
|
354
596
|
55,
|
|
355
597
|
97
|
|
356
598
|
])) return "image/gif";
|
|
357
|
-
if (
|
|
599
|
+
if (matchesBytes(head, [
|
|
358
600
|
71,
|
|
359
601
|
73,
|
|
360
602
|
70,
|
|
@@ -362,35 +604,35 @@ function detectMIME(head) {
|
|
|
362
604
|
57,
|
|
363
605
|
97
|
|
364
606
|
])) return "image/gif";
|
|
365
|
-
if (
|
|
607
|
+
if (matchesBytes(head, [
|
|
366
608
|
82,
|
|
367
609
|
73,
|
|
368
610
|
70,
|
|
369
611
|
70
|
|
370
|
-
]) &&
|
|
612
|
+
]) && matchesBytes(head, [
|
|
371
613
|
87,
|
|
372
614
|
69,
|
|
373
615
|
66,
|
|
374
616
|
80
|
|
375
617
|
], 8)) return "image/webp";
|
|
376
|
-
if (
|
|
618
|
+
if (matchesBytes(head, [
|
|
377
619
|
37,
|
|
378
620
|
80,
|
|
379
621
|
68,
|
|
380
622
|
70,
|
|
381
623
|
45
|
|
382
624
|
])) return "application/pdf";
|
|
383
|
-
if (
|
|
625
|
+
if (matchesBytes(head, [
|
|
384
626
|
80,
|
|
385
627
|
75,
|
|
386
628
|
3,
|
|
387
629
|
4
|
|
388
|
-
]) ||
|
|
630
|
+
]) || matchesBytes(head, [
|
|
389
631
|
80,
|
|
390
632
|
75,
|
|
391
633
|
5,
|
|
392
634
|
6
|
|
393
|
-
]) ||
|
|
635
|
+
]) || matchesBytes(head, [
|
|
394
636
|
80,
|
|
395
637
|
75,
|
|
396
638
|
7,
|
|
@@ -398,6 +640,24 @@ function detectMIME(head) {
|
|
|
398
640
|
])) return "application/zip";
|
|
399
641
|
}
|
|
400
642
|
/**
|
|
643
|
+
* Whether `bytes` contains `signature` at the requested offset.
|
|
644
|
+
*
|
|
645
|
+
* @param bytes - The bytes to inspect
|
|
646
|
+
* @param signature - The exact byte sequence to match
|
|
647
|
+
* @param offset - The starting byte offset, defaulting to zero
|
|
648
|
+
* @returns `true` when the complete signature matches
|
|
649
|
+
*
|
|
650
|
+
* @example
|
|
651
|
+
* ```ts
|
|
652
|
+
* matchesBytes(Uint8Array.from([0, 0x50, 0x4b]), [0x50, 0x4b], 1) // true
|
|
653
|
+
* ```
|
|
654
|
+
*/
|
|
655
|
+
function matchesBytes(bytes, signature, offset = 0) {
|
|
656
|
+
if (bytes.length < offset + signature.length) return false;
|
|
657
|
+
for (let index = 0; index < signature.length; index += 1) if (bytes[offset + index] !== signature[index]) return false;
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
401
661
|
* Extract the `boundary` parameter from a `Content-Type` header, or
|
|
402
662
|
* `undefined` when the request is not `multipart/form-data`.
|
|
403
663
|
*
|
|
@@ -441,11 +701,6 @@ function resolveMultipartLimits(limits) {
|
|
|
441
701
|
};
|
|
442
702
|
}
|
|
443
703
|
/**
|
|
444
|
-
* Memoized `Promise` for `parseMultipartRequest`'s lazily-created default
|
|
445
|
-
* staging directory — created once per process via {@link resolveDefaultDirectory}.
|
|
446
|
-
*/
|
|
447
|
-
var defaultDirectory;
|
|
448
|
-
/**
|
|
449
704
|
* Resolve `parseMultipartRequest`'s default staging directory when the
|
|
450
705
|
* caller did not configure one — a process-owned directory created ONCE
|
|
451
706
|
* (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
|
|
@@ -459,12 +714,7 @@ var defaultDirectory;
|
|
|
459
714
|
* ```
|
|
460
715
|
*/
|
|
461
716
|
function resolveDefaultDirectory() {
|
|
462
|
-
|
|
463
|
-
const path = await mkdtemp(join(tmpdir(), "orkestrel-multipart-"));
|
|
464
|
-
await chmod(path, 448);
|
|
465
|
-
return path;
|
|
466
|
-
})();
|
|
467
|
-
return defaultDirectory;
|
|
717
|
+
return MultipartParser.directory();
|
|
468
718
|
}
|
|
469
719
|
/**
|
|
470
720
|
* Parse one multipart part's raw header block into its `name` (from
|
|
@@ -551,177 +801,7 @@ async function parseMultipartRequest(request, options = {}) {
|
|
|
551
801
|
const limits = resolveMultipartLimits(options.limits);
|
|
552
802
|
const allowed = options.allowed;
|
|
553
803
|
const directory = options.directory ?? await resolveDefaultDirectory();
|
|
554
|
-
|
|
555
|
-
const files = Object.create(null);
|
|
556
|
-
const fields = Object.create(null);
|
|
557
|
-
let fileCount = 0;
|
|
558
|
-
let fieldCount = 0;
|
|
559
|
-
let totalBytes = 0;
|
|
560
|
-
let aborted = false;
|
|
561
|
-
async function cleanup() {
|
|
562
|
-
for (const path of staged) try {
|
|
563
|
-
await unlink(path);
|
|
564
|
-
} catch {}
|
|
565
|
-
}
|
|
566
|
-
const reader = request.body.getReader();
|
|
567
|
-
let buffer = Buffer.alloc(0);
|
|
568
|
-
let ended = false;
|
|
569
|
-
const onAbort = () => {
|
|
570
|
-
aborted = true;
|
|
571
|
-
};
|
|
572
|
-
request.signal.addEventListener("abort", onAbort);
|
|
573
|
-
async function pull() {
|
|
574
|
-
if (aborted || request.signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
575
|
-
if (ended) return false;
|
|
576
|
-
const { done, value } = await reader.read();
|
|
577
|
-
if (done) {
|
|
578
|
-
ended = true;
|
|
579
|
-
return false;
|
|
580
|
-
}
|
|
581
|
-
totalBytes += value.byteLength;
|
|
582
|
-
if (totalBytes > limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
|
|
583
|
-
buffer = Buffer.concat([buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
|
|
584
|
-
return true;
|
|
585
|
-
}
|
|
586
|
-
try {
|
|
587
|
-
const openMarker = Buffer.from(`--${boundary}`);
|
|
588
|
-
let preambleScanned = 0;
|
|
589
|
-
let index = buffer.indexOf(openMarker);
|
|
590
|
-
while (index === -1) {
|
|
591
|
-
const carry = openMarker.length - 1;
|
|
592
|
-
if (buffer.length > carry) {
|
|
593
|
-
const drop = buffer.length - carry;
|
|
594
|
-
preambleScanned += drop;
|
|
595
|
-
if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
596
|
-
buffer = buffer.subarray(drop);
|
|
597
|
-
}
|
|
598
|
-
if (!await pull()) throw new MultipartError("malformed", "missing multipart boundary");
|
|
599
|
-
index = buffer.indexOf(openMarker);
|
|
600
|
-
}
|
|
601
|
-
buffer = buffer.subarray(index + openMarker.length);
|
|
602
|
-
for (;;) {
|
|
603
|
-
while (buffer.length < 2) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
604
|
-
if (buffer[0] === 45 && buffer[1] === 45) break;
|
|
605
|
-
if (buffer[0] !== 13 || buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
|
|
606
|
-
buffer = buffer.subarray(2);
|
|
607
|
-
let headerEnd = buffer.indexOf("\r\n\r\n");
|
|
608
|
-
while (headerEnd === -1) {
|
|
609
|
-
if (buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
610
|
-
if (!await pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
|
|
611
|
-
headerEnd = buffer.indexOf("\r\n\r\n");
|
|
612
|
-
}
|
|
613
|
-
const headerBlock = buffer.subarray(0, headerEnd).toString("utf8");
|
|
614
|
-
buffer = buffer.subarray(headerEnd + 4);
|
|
615
|
-
const { name, filename, contentType } = parsePartHeaders(headerBlock);
|
|
616
|
-
if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
|
|
617
|
-
const partDelimiter = Buffer.from(`\r\n--${boundary}`);
|
|
618
|
-
if (filename !== void 0) {
|
|
619
|
-
if (filename !== "") {
|
|
620
|
-
fileCount += 1;
|
|
621
|
-
if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
622
|
-
}
|
|
623
|
-
const path = join(directory, randomUUID());
|
|
624
|
-
staged.push(path);
|
|
625
|
-
const handle = await open(path, "w", 384);
|
|
626
|
-
let size = 0;
|
|
627
|
-
let head = Buffer.alloc(0);
|
|
628
|
-
try {
|
|
629
|
-
for (;;) {
|
|
630
|
-
const boundaryIndex = buffer.indexOf(partDelimiter);
|
|
631
|
-
if (boundaryIndex === -1) {
|
|
632
|
-
const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
|
|
633
|
-
if (safeLength > 0) {
|
|
634
|
-
const chunk = buffer.subarray(0, safeLength);
|
|
635
|
-
size += chunk.length;
|
|
636
|
-
if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
637
|
-
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
638
|
-
await handle.write(chunk);
|
|
639
|
-
buffer = buffer.subarray(safeLength);
|
|
640
|
-
}
|
|
641
|
-
if (!await pull()) throw new MultipartError("malformed", "unterminated multipart file part");
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
const chunk = buffer.subarray(0, boundaryIndex);
|
|
645
|
-
size += chunk.length;
|
|
646
|
-
if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
647
|
-
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
648
|
-
await handle.write(chunk);
|
|
649
|
-
buffer = buffer.subarray(boundaryIndex + 2);
|
|
650
|
-
break;
|
|
651
|
-
}
|
|
652
|
-
} finally {
|
|
653
|
-
await handle.close();
|
|
654
|
-
}
|
|
655
|
-
if (filename === "" && size === 0) {
|
|
656
|
-
await unlink(path);
|
|
657
|
-
staged.splice(staged.indexOf(path), 1);
|
|
658
|
-
} else {
|
|
659
|
-
if (filename === "") {
|
|
660
|
-
fileCount += 1;
|
|
661
|
-
if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
662
|
-
}
|
|
663
|
-
const detected = detectMIME(head);
|
|
664
|
-
const declared = contentType ?? "application/octet-stream";
|
|
665
|
-
const validated = detected !== void 0 && detected === declared;
|
|
666
|
-
if (allowed !== void 0) {
|
|
667
|
-
if (!(detected !== void 0 && allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
|
|
668
|
-
}
|
|
669
|
-
if (isDangerousKey(name)) {
|
|
670
|
-
await unlink(path);
|
|
671
|
-
staged.splice(staged.indexOf(path), 1);
|
|
672
|
-
} else {
|
|
673
|
-
const record = createUploadedFile({
|
|
674
|
-
field: name,
|
|
675
|
-
name: filename,
|
|
676
|
-
size,
|
|
677
|
-
mime: detected ?? declared,
|
|
678
|
-
validated,
|
|
679
|
-
status: "staged",
|
|
680
|
-
path
|
|
681
|
-
});
|
|
682
|
-
const existing = files[name];
|
|
683
|
-
if (existing === void 0) files[name] = [record];
|
|
684
|
-
else existing.push(record);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
} else {
|
|
688
|
-
fieldCount += 1;
|
|
689
|
-
if (fieldCount > limits.fields) throw new MultipartError("limit", "too many multipart fields");
|
|
690
|
-
let value = Buffer.alloc(0);
|
|
691
|
-
for (;;) {
|
|
692
|
-
const boundaryIndex = buffer.indexOf(partDelimiter);
|
|
693
|
-
if (boundaryIndex === -1) {
|
|
694
|
-
const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
|
|
695
|
-
if (safeLength > 0) {
|
|
696
|
-
value = Buffer.concat([value, buffer.subarray(0, safeLength)]);
|
|
697
|
-
if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
698
|
-
buffer = buffer.subarray(safeLength);
|
|
699
|
-
}
|
|
700
|
-
if (!await pull()) throw new MultipartError("malformed", "unterminated multipart field part");
|
|
701
|
-
continue;
|
|
702
|
-
}
|
|
703
|
-
value = Buffer.concat([value, buffer.subarray(0, boundaryIndex)]);
|
|
704
|
-
if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
705
|
-
buffer = buffer.subarray(boundaryIndex + 2);
|
|
706
|
-
break;
|
|
707
|
-
}
|
|
708
|
-
if (!isDangerousKey(name)) fields[name] = value.toString("utf8");
|
|
709
|
-
}
|
|
710
|
-
while (buffer.length < openMarker.length) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
711
|
-
buffer = buffer.subarray(openMarker.length);
|
|
712
|
-
}
|
|
713
|
-
} catch (error) {
|
|
714
|
-
await cleanup();
|
|
715
|
-
await reader.cancel().catch(() => {});
|
|
716
|
-
throw error;
|
|
717
|
-
} finally {
|
|
718
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
719
|
-
if (!ended) await reader.cancel().catch(() => {});
|
|
720
|
-
}
|
|
721
|
-
return {
|
|
722
|
-
files: Object.freeze(files),
|
|
723
|
-
fields: Object.freeze(fields)
|
|
724
|
-
};
|
|
804
|
+
return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
|
|
725
805
|
}
|
|
726
806
|
/**
|
|
727
807
|
* Build a frozen {@link UploadedFileInterface} record.
|
|
@@ -934,10 +1014,6 @@ function createStatic(options) {
|
|
|
934
1014
|
const useETag = options.etag ?? true;
|
|
935
1015
|
const fallback = options.fallback === true ? { exclude: DEFAULT_STATIC_FALLBACK_EXCLUDE } : options.fallback === false || options.fallback === void 0 ? void 0 : { exclude: options.fallback.exclude ?? "/api" };
|
|
936
1016
|
let canonicalRootPromise;
|
|
937
|
-
function canonicalRoot() {
|
|
938
|
-
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
939
|
-
return canonicalRootPromise;
|
|
940
|
-
}
|
|
941
1017
|
return async (request, context, next) => {
|
|
942
1018
|
if (context.method !== "GET" && context.method !== "HEAD") return next();
|
|
943
1019
|
const target = resolveStaticPath(root, options.prefix, context.url.pathname);
|
|
@@ -947,47 +1023,83 @@ function createStatic(options) {
|
|
|
947
1023
|
if (dotfiles === "deny") throw new HTTPError(403, "forbidden");
|
|
948
1024
|
if (dotfiles === "ignore") return next();
|
|
949
1025
|
}
|
|
950
|
-
let resolvedPath;
|
|
1026
|
+
let resolvedPath = target;
|
|
1027
|
+
let fallbackNeeded = false;
|
|
951
1028
|
try {
|
|
952
|
-
|
|
1029
|
+
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1030
|
+
const [rootReal, targetReal] = await Promise.all([canonicalRootPromise, realpath(target)]);
|
|
953
1031
|
if (!isContainedPath(targetReal, rootReal)) return next();
|
|
954
1032
|
resolvedPath = targetReal;
|
|
955
1033
|
} catch {
|
|
956
|
-
|
|
1034
|
+
fallbackNeeded = true;
|
|
957
1035
|
}
|
|
958
1036
|
let directoryInfo;
|
|
959
|
-
try {
|
|
1037
|
+
if (!fallbackNeeded) try {
|
|
960
1038
|
directoryInfo = await stat(resolvedPath);
|
|
961
1039
|
} catch {
|
|
962
|
-
|
|
1040
|
+
fallbackNeeded = true;
|
|
963
1041
|
}
|
|
964
|
-
if (directoryInfo
|
|
1042
|
+
if (directoryInfo?.isDirectory()) {
|
|
965
1043
|
resolvedPath = join(resolvedPath, index);
|
|
966
1044
|
try {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
resolvedPath = indexReal;
|
|
1045
|
+
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1046
|
+
const [rootReal, indexReal] = await Promise.all([canonicalRootPromise, realpath(resolvedPath)]);
|
|
1047
|
+
if (isContainedPath(indexReal, rootReal)) resolvedPath = indexReal;
|
|
1048
|
+
else fallbackNeeded = true;
|
|
970
1049
|
} catch {
|
|
971
|
-
|
|
1050
|
+
fallbackNeeded = true;
|
|
972
1051
|
}
|
|
973
1052
|
}
|
|
974
1053
|
let handle;
|
|
975
|
-
|
|
1054
|
+
let info;
|
|
1055
|
+
if (!fallbackNeeded) try {
|
|
976
1056
|
handle = await open(resolvedPath, "r");
|
|
977
1057
|
} catch {
|
|
978
|
-
|
|
1058
|
+
fallbackNeeded = true;
|
|
979
1059
|
}
|
|
980
|
-
|
|
981
|
-
try {
|
|
1060
|
+
if (handle !== void 0) try {
|
|
982
1061
|
info = await handle.stat();
|
|
983
1062
|
} catch {
|
|
984
1063
|
await handle.close();
|
|
985
|
-
|
|
1064
|
+
handle = void 0;
|
|
1065
|
+
fallbackNeeded = true;
|
|
986
1066
|
}
|
|
987
|
-
if (!info.isFile()) {
|
|
1067
|
+
if (handle !== void 0 && info !== void 0 && !info.isFile()) {
|
|
988
1068
|
await handle.close();
|
|
989
|
-
|
|
1069
|
+
handle = void 0;
|
|
1070
|
+
info = void 0;
|
|
1071
|
+
fallbackNeeded = true;
|
|
1072
|
+
}
|
|
1073
|
+
if (fallbackNeeded) {
|
|
1074
|
+
const shellPath = resolveStaticFallbackPath(root, index, fallback?.exclude ?? "/api", context.method, context.url.pathname, request.headers.get("accept") ?? "");
|
|
1075
|
+
if (fallback === void 0 || shellPath === void 0) return next();
|
|
1076
|
+
let shellReal;
|
|
1077
|
+
try {
|
|
1078
|
+
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1079
|
+
const [rootReal, candidate] = await Promise.all([canonicalRootPromise, realpath(shellPath)]);
|
|
1080
|
+
if (!isContainedPath(candidate, rootReal)) return next();
|
|
1081
|
+
shellReal = candidate;
|
|
1082
|
+
} catch {
|
|
1083
|
+
return next();
|
|
1084
|
+
}
|
|
1085
|
+
let shellHandle;
|
|
1086
|
+
try {
|
|
1087
|
+
shellHandle = await open(shellReal, "r");
|
|
1088
|
+
} catch {
|
|
1089
|
+
return next();
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
const body = streamFile(shellHandle);
|
|
1093
|
+
return new Response(body, {
|
|
1094
|
+
status: 200,
|
|
1095
|
+
headers: new Headers({ "content-type": lookupContentType(shellReal) })
|
|
1096
|
+
});
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
await shellHandle.close().catch(() => {});
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
990
1101
|
}
|
|
1102
|
+
if (handle === void 0 || info === void 0) return next();
|
|
991
1103
|
let streaming = false;
|
|
992
1104
|
try {
|
|
993
1105
|
const headers = new Headers({
|
|
@@ -1049,32 +1161,6 @@ function createStatic(options) {
|
|
|
1049
1161
|
if (!streaming) await handle.close().catch(() => {});
|
|
1050
1162
|
throw error;
|
|
1051
1163
|
}
|
|
1052
|
-
function trySpaFallback() {
|
|
1053
|
-
if (fallback === void 0) return next();
|
|
1054
|
-
if (context.method !== "GET") return next();
|
|
1055
|
-
if (extname(context.url.pathname) !== "") return next();
|
|
1056
|
-
const accept = request.headers.get("accept") ?? "";
|
|
1057
|
-
if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
|
|
1058
|
-
if (isUnderPath(context.url.pathname, fallback.exclude)) return next();
|
|
1059
|
-
const shellPath = join(root, index);
|
|
1060
|
-
return Promise.all([canonicalRoot(), realpath(shellPath)]).then(([rootReal, shellReal]) => {
|
|
1061
|
-
if (!isContainedPath(shellReal, rootReal)) return next();
|
|
1062
|
-
return open(shellReal, "r").then((shellHandle) => {
|
|
1063
|
-
let shellStreaming = false;
|
|
1064
|
-
try {
|
|
1065
|
-
const body = streamFile(shellHandle);
|
|
1066
|
-
shellStreaming = true;
|
|
1067
|
-
return new Response(body, {
|
|
1068
|
-
status: 200,
|
|
1069
|
-
headers: new Headers({ "content-type": lookupContentType(shellReal) })
|
|
1070
|
-
});
|
|
1071
|
-
} catch (error) {
|
|
1072
|
-
if (!shellStreaming) shellHandle.close().catch(() => {});
|
|
1073
|
-
throw error;
|
|
1074
|
-
}
|
|
1075
|
-
});
|
|
1076
|
-
}).catch(() => next());
|
|
1077
|
-
}
|
|
1078
1164
|
};
|
|
1079
1165
|
}
|
|
1080
1166
|
/**
|
|
@@ -1118,7 +1204,7 @@ function createMultipart(options = {}) {
|
|
|
1118
1204
|
throw error;
|
|
1119
1205
|
}
|
|
1120
1206
|
if (body === void 0) return next();
|
|
1121
|
-
context.state
|
|
1207
|
+
Object.assign(context.state, { multipart: body });
|
|
1122
1208
|
try {
|
|
1123
1209
|
return await next();
|
|
1124
1210
|
} catch (error) {
|
|
@@ -1163,18 +1249,17 @@ function createCompression(options) {
|
|
|
1163
1249
|
const threshold = options?.threshold ?? DEFAULT_COMPRESSION_THRESHOLD;
|
|
1164
1250
|
const filter = options?.filter;
|
|
1165
1251
|
const encodings = ["gzip", "deflate"];
|
|
1166
|
-
const gzipAsync = promisify(gzip);
|
|
1167
|
-
const deflateAsync = promisify(deflate);
|
|
1168
1252
|
return async (request, context, next) => {
|
|
1169
|
-
|
|
1253
|
+
const response = await next();
|
|
1254
|
+
return compressResponse(request, context, response, {
|
|
1170
1255
|
threshold,
|
|
1171
|
-
filter,
|
|
1256
|
+
...filter !== void 0 ? { filter } : {},
|
|
1172
1257
|
encodings,
|
|
1173
|
-
compress:
|
|
1258
|
+
compress: compressNodeBytes
|
|
1174
1259
|
});
|
|
1175
1260
|
};
|
|
1176
1261
|
}
|
|
1177
1262
|
//#endregion
|
|
1178
|
-
export { DEFAULT_CONTENT_TYPE, DEFAULT_MULTIPART_FIELD, DEFAULT_MULTIPART_FIELDS, DEFAULT_MULTIPART_FILE, DEFAULT_MULTIPART_FILES, DEFAULT_MULTIPART_TOTAL, DEFAULT_STATIC_FALLBACK_EXCLUDE, DEFAULT_STATIC_INDEX, EXTENSION_TYPES, MULTIPART_MAX_HEADER_BLOCK, MULTIPART_MAX_PREAMBLE, MULTIPART_REASON_STATUS, MultipartError, RESERVED_DEVICE_NAMES, computeFileETag, createCompression, createMultipart, createStatic, createUploadedFile, detectMIME, isContainedPath, isDotfilePath, isMultipartError, isReservedDeviceName, isUnderPath, lookupContentType, moveUploadedFile, multipartBoundary, parseMultipartRequest, parsePartHeaders, readUploadedFile, resolveDefaultDirectory, resolveMultipartLimits, resolveStaticPath, streamFile, streamUploadedFile, unlinkStagedFiles };
|
|
1263
|
+
export { DEFAULT_CONTENT_TYPE, DEFAULT_MULTIPART_FIELD, DEFAULT_MULTIPART_FIELDS, DEFAULT_MULTIPART_FILE, DEFAULT_MULTIPART_FILES, DEFAULT_MULTIPART_TOTAL, DEFAULT_STATIC_FALLBACK_EXCLUDE, DEFAULT_STATIC_INDEX, EXTENSION_TYPES, MULTIPART_MAX_HEADER_BLOCK, MULTIPART_MAX_PREAMBLE, MULTIPART_REASON_STATUS, MultipartError, RESERVED_DEVICE_NAMES, compressNodeBytes, computeFileETag, createCompression, createMultipart, createStatic, createUploadedFile, detectMIME, isContainedPath, isDotfilePath, isMultipartError, isReservedDeviceName, isUnderPath, lookupContentType, matchesBytes, moveUploadedFile, multipartBoundary, parseMultipartRequest, parsePartHeaders, readUploadedFile, resolveDefaultDirectory, resolveMultipartLimits, resolveStaticFallbackPath, resolveStaticPath, streamFile, streamUploadedFile, unlinkStagedFiles };
|
|
1179
1264
|
|
|
1180
1265
|
//# sourceMappingURL=index.js.map
|