@orkestrel/middleware 0.0.18 → 0.0.20
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/README.md +20 -15
- package/dist/src/core/index.cjs +393 -312
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +497 -328
- package/dist/src/core/index.d.ts +497 -328
- package/dist/src/core/index.js +391 -311
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +486 -422
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +316 -232
- package/dist/src/server/index.d.ts +316 -232
- package/dist/src/server/index.js +478 -417
- package/dist/src/server/index.js.map +1 -1
- package/package.json +21 -23
package/dist/src/server/index.js
CHANGED
|
@@ -1,42 +1,60 @@
|
|
|
1
|
+
import { HTTPError, computeBodyETag, isDangerousKey, matchesETag, negotiateEncoding, parseRange } from "@orkestrel/server";
|
|
1
2
|
import { createReadStream } from "node:fs";
|
|
2
3
|
import { chmod, copyFile, mkdtemp, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
3
4
|
import { extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { brotliDecompressSync, deflate, gzip } from "node:zlib";
|
|
6
|
-
import { isArrayBuffer, isFiniteNumber, isFunction, isObject,
|
|
7
|
+
import { isArrayBuffer, isError, isFiniteNumber, isFunction, isObject, isString, isUint8Array } from "@orkestrel/contract";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { HTTPError, computeBodyETag, isDangerousKey, matchesETag, negotiateEncoding, parseRange } from "@orkestrel/server";
|
|
10
10
|
import { DEFAULT_COMPRESSION_THRESHOLD, compressResponse } from "../core/index.js";
|
|
11
11
|
//#region src/server/constants.ts
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Holds the HTTP status `createMultipart` renders for each {@link MultipartErrorCode}:
|
|
14
|
+
* `'limit'` is 413, `'malformed'` is 400, and `'rejected'` is 415.
|
|
15
|
+
*/
|
|
16
|
+
var MULTIPART_STATUS = Object.freeze({
|
|
14
17
|
limit: 413,
|
|
15
18
|
malformed: 400,
|
|
16
19
|
rejected: 415
|
|
17
20
|
});
|
|
18
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Holds the `Symbol.for` brand {@link MultipartError} carries so
|
|
23
|
+
* {@link isMultipartError} recognizes an instance across duplicate copies of
|
|
24
|
+
* this package — a registry symbol rather than a module-local `Symbol()`,
|
|
25
|
+
* which would mint an unequal symbol per copy.
|
|
26
|
+
*/
|
|
27
|
+
var MULTIPART_ERROR_BRAND = Symbol.for("@orkestrel/middleware.MultipartError");
|
|
28
|
+
/** Names `'index.html'`, `createStatic`'s default directory-index filename. */
|
|
19
29
|
var DEFAULT_STATIC_INDEX = "index.html";
|
|
20
|
-
/** `createStatic`'s `fallback: true` default excluded path prefix. */
|
|
30
|
+
/** Names `'/api'`, `createStatic`'s `fallback: true` default excluded path prefix. */
|
|
21
31
|
var DEFAULT_STATIC_FALLBACK_EXCLUDE = "/api";
|
|
22
|
-
/**
|
|
32
|
+
/** Names `'ignore'`, `createStatic`'s default policy for a path carrying a dotfile segment. */
|
|
33
|
+
var DEFAULT_STATIC_DOTFILES = "ignore";
|
|
34
|
+
/**
|
|
35
|
+
* Lists `['gzip', 'deflate']`, the content-codings the node face's `createCompression`
|
|
36
|
+
* offers — what `node:zlib` guarantees on every Node runtime, so this face never
|
|
37
|
+
* feature-detects.
|
|
38
|
+
*/
|
|
39
|
+
var NODE_COMPRESSION_ENCODINGS = Object.freeze(["gzip", "deflate"]);
|
|
40
|
+
/** Names `'application/octet-stream'`, the MIME type served when a file extension has no known mapping. */
|
|
23
41
|
var DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
24
|
-
/** `createMultipart`'s default per-file byte-size cap. */
|
|
25
|
-
var
|
|
26
|
-
/** `createMultipart`'s default maximum file-part count. */
|
|
27
|
-
var
|
|
28
|
-
/** `createMultipart`'s default per-field byte-size cap. */
|
|
29
|
-
var
|
|
30
|
-
/** `createMultipart`'s default maximum field-part count. */
|
|
31
|
-
var
|
|
32
|
-
/** `createMultipart`'s default combined request-body byte-size cap. */
|
|
42
|
+
/** Holds `10_485_760`, `createMultipart`'s default per-file byte-size cap. */
|
|
43
|
+
var DEFAULT_MULTIPART_FILE_SIZE = 10485760;
|
|
44
|
+
/** Holds `10`, `createMultipart`'s default maximum file-part count. */
|
|
45
|
+
var DEFAULT_MULTIPART_FILE_COUNT = 10;
|
|
46
|
+
/** Holds `65_536`, `createMultipart`'s default per-field byte-size cap. */
|
|
47
|
+
var DEFAULT_MULTIPART_FIELD_SIZE = 65536;
|
|
48
|
+
/** Holds `100`, `createMultipart`'s default maximum field-part count. */
|
|
49
|
+
var DEFAULT_MULTIPART_FIELD_COUNT = 100;
|
|
50
|
+
/** Holds `52_428_800`, `createMultipart`'s default combined request-body byte-size cap. */
|
|
33
51
|
var DEFAULT_MULTIPART_TOTAL = 52428800;
|
|
34
|
-
/**
|
|
52
|
+
/** Holds `16_384`, the maximum bytes a single multipart part's header block may occupy before it is malformed. */
|
|
35
53
|
var MULTIPART_MAX_HEADER_BLOCK = 16384;
|
|
36
|
-
/**
|
|
54
|
+
/** Holds `65_536`, the maximum bytes scanned before the first multipart boundary is found before it is malformed. */
|
|
37
55
|
var MULTIPART_MAX_PREAMBLE = 65536;
|
|
38
56
|
/**
|
|
39
|
-
* Windows reserved device-name stems (CVE-2025-27210) — matched
|
|
57
|
+
* Lists the Windows reserved device-name stems (CVE-2025-27210) — matched
|
|
40
58
|
* case-insensitively against the segment's stem (before its first `.`).
|
|
41
59
|
*/
|
|
42
60
|
var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
|
|
@@ -63,7 +81,7 @@ var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
|
|
|
63
81
|
"LPT8",
|
|
64
82
|
"LPT9"
|
|
65
83
|
]));
|
|
66
|
-
/**
|
|
84
|
+
/** Holds the file-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. */
|
|
67
85
|
var EXTENSION_TYPES = Object.freeze({
|
|
68
86
|
".html": "text/html; charset=utf-8",
|
|
69
87
|
".htm": "text/html; charset=utf-8",
|
|
@@ -89,15 +107,18 @@ var EXTENSION_TYPES = Object.freeze({
|
|
|
89
107
|
//#endregion
|
|
90
108
|
//#region src/server/errors.ts
|
|
91
109
|
/**
|
|
92
|
-
*
|
|
110
|
+
* Represents an error `createMultipart` throws when a streamed multipart request fails
|
|
93
111
|
* a mid-stream limit, is structurally malformed, or has a file whose sniffed
|
|
94
112
|
* bytes are rejected by the configured `allowed` MIME list.
|
|
95
113
|
*
|
|
96
114
|
* @remarks
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
115
|
+
* Extends the peer `HTTPError`, which already publishes the `status`,
|
|
116
|
+
* `context`, and brand members every fleet error of this shape carries, and
|
|
117
|
+
* adds the machine-readable `code` axis a caller narrows on. `status` is
|
|
118
|
+
* derived from `code` through {@link MULTIPART_STATUS} (limit → 413, malformed →
|
|
119
|
+
* 400, rejected → 415), so `createBoundary` — or any other `isHTTPError`-aware
|
|
120
|
+
* renderer — maps it without knowing this face's error type. Narrow a caught
|
|
121
|
+
* value to the richer type with {@link isMultipartError}.
|
|
101
122
|
*
|
|
102
123
|
* @example
|
|
103
124
|
* ```ts
|
|
@@ -106,29 +127,26 @@ var EXTENSION_TYPES = Object.freeze({
|
|
|
106
127
|
* throw new MultipartError('limit', 'too many files')
|
|
107
128
|
* ```
|
|
108
129
|
*/
|
|
109
|
-
var MultipartError = class extends
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
context
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
this.reason = reason;
|
|
117
|
-
if (context !== void 0) this.context = context;
|
|
118
|
-
Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
|
|
130
|
+
var MultipartError = class extends HTTPError {
|
|
131
|
+
code;
|
|
132
|
+
[MULTIPART_ERROR_BRAND] = true;
|
|
133
|
+
constructor(code, message, context) {
|
|
134
|
+
super(MULTIPART_STATUS[code], message, context);
|
|
135
|
+
this.name = "MultipartError";
|
|
136
|
+
this.code = code;
|
|
119
137
|
}
|
|
120
138
|
};
|
|
121
139
|
/**
|
|
122
|
-
*
|
|
140
|
+
* Narrows an unknown caught value to a {@link MultipartError}.
|
|
123
141
|
*
|
|
124
142
|
* @remarks
|
|
125
143
|
* Structural, not `instanceof` — tests that `value` is a non-null object
|
|
126
|
-
* carrying
|
|
127
|
-
* parser's
|
|
144
|
+
* carrying {@link MULTIPART_ERROR_BRAND}, a numeric `status`, and a `code`
|
|
145
|
+
* in the parser's {@link MultipartErrorCode} set (`'limit' | 'malformed' | 'rejected'`).
|
|
128
146
|
* Total: never throws, returns `false` for any off-shape input.
|
|
129
147
|
*
|
|
130
148
|
* @param value - The value to test (typically a `catch` binding)
|
|
131
|
-
* @returns
|
|
149
|
+
* @returns True if `value` is a {@link MultipartError}; false otherwise
|
|
132
150
|
*
|
|
133
151
|
* @example
|
|
134
152
|
* ```ts
|
|
@@ -137,233 +155,29 @@ var MultipartError = class extends Error {
|
|
|
137
155
|
* try {
|
|
138
156
|
* await parse(request)
|
|
139
157
|
* } catch (error) {
|
|
140
|
-
* if (isMultipartError(error)) console.log(error.status, error.
|
|
158
|
+
* if (isMultipartError(error)) console.log(error.status, error.code)
|
|
141
159
|
* }
|
|
142
160
|
* ```
|
|
143
161
|
*/
|
|
144
162
|
function isMultipartError(value) {
|
|
145
163
|
if (typeof value !== "object" || value === null) return false;
|
|
146
|
-
if (!(
|
|
147
|
-
if (!("status" in value) || !("
|
|
164
|
+
if (!(MULTIPART_ERROR_BRAND in value)) return false;
|
|
165
|
+
if (!("status" in value) || !("code" in value)) return false;
|
|
148
166
|
if (typeof value.status !== "number") return false;
|
|
149
|
-
if (value.
|
|
167
|
+
if (value.code !== "limit" && value.code !== "malformed" && value.code !== "rejected") return false;
|
|
150
168
|
return true;
|
|
151
169
|
}
|
|
152
170
|
//#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
|
|
357
171
|
//#region src/server/helpers.ts
|
|
358
172
|
/**
|
|
359
|
-
*
|
|
173
|
+
* Checks whether `pathname` is `prefix` itself or lies under it on a segment
|
|
360
174
|
* boundary — the shared under-path test `resolveStaticPath`'s prefix strip
|
|
361
175
|
* and `createStatic`'s SPA-fallback `exclude` both apply, so `exclude:
|
|
362
176
|
* '/api'` matches `/api` and `/api/x` but never `/apifoo`.
|
|
363
177
|
*
|
|
364
178
|
* @param pathname - The request pathname to test
|
|
365
179
|
* @param prefix - The path prefix to test against
|
|
366
|
-
* @returns
|
|
180
|
+
* @returns True if `pathname` equals `prefix` or starts with `prefix` + `/`; false otherwise
|
|
367
181
|
*
|
|
368
182
|
* @example
|
|
369
183
|
* ```ts
|
|
@@ -377,13 +191,19 @@ function isUnderPath(pathname, prefix) {
|
|
|
377
191
|
return pathname.startsWith(boundary);
|
|
378
192
|
}
|
|
379
193
|
/**
|
|
380
|
-
*
|
|
194
|
+
* Resolves the fixed SPA shell path when a static-file miss is eligible for
|
|
381
195
|
* fallback.
|
|
382
196
|
*
|
|
197
|
+
* @remarks
|
|
198
|
+
* `GET` and `HEAD` are both eligible, and resolve the same shell: `HEAD` is
|
|
199
|
+
* defined as `GET` without a body (RFC 9110 §9.3.2), so a navigation probe
|
|
200
|
+
* that answered `404` while its `GET` answered `200` would report a resource
|
|
201
|
+
* the very next request serves.
|
|
202
|
+
*
|
|
383
203
|
* @param root - The configured static root
|
|
384
204
|
* @param index - The configured shell filename
|
|
385
205
|
* @param exclude - The URL prefix excluded from fallback
|
|
386
|
-
* @param method - The request method
|
|
206
|
+
* @param method - The request method; only `GET` and `HEAD` are eligible
|
|
387
207
|
* @param pathname - The request pathname
|
|
388
208
|
* @param accept - The request's `Accept` header value
|
|
389
209
|
* @returns The fixed shell path, or `undefined` when fallback is ineligible
|
|
@@ -395,19 +215,19 @@ function isUnderPath(pathname, prefix) {
|
|
|
395
215
|
* ```
|
|
396
216
|
*/
|
|
397
217
|
function resolveStaticFallbackPath(root, index, exclude, method, pathname, accept) {
|
|
398
|
-
if (method !== "GET") return void 0;
|
|
218
|
+
if (method !== "GET" && method !== "HEAD") return void 0;
|
|
399
219
|
if (extname(pathname) !== "") return void 0;
|
|
400
220
|
if (!accept.includes("text/html") && !accept.includes("*/*")) return void 0;
|
|
401
221
|
if (isUnderPath(pathname, exclude)) return void 0;
|
|
402
222
|
return join(root, index);
|
|
403
223
|
}
|
|
404
224
|
/**
|
|
405
|
-
*
|
|
406
|
-
*
|
|
225
|
+
* Checks whether `child` is `parent` itself or lies inside it on-disk — the
|
|
226
|
+
* filesystem containment predicate `createStatic` applies to `fs.realpath`
|
|
407
227
|
* output (never to a URL pathname — that is {@link isUnderPath}'s job).
|
|
408
228
|
*
|
|
409
229
|
* @remarks
|
|
410
|
-
* Argument order is `(child, parent)` — deliberately the
|
|
230
|
+
* Argument order is `(child, parent)` — deliberately the opposite conceptual
|
|
411
231
|
* order from {@link isUnderPath}`(pathname, prefix)`, so a call site cannot
|
|
412
232
|
* casually swap one predicate in for the other. Built on `path.relative`,
|
|
413
233
|
* this is separator-correct on both POSIX (`/`) and win32 (`\`) — unlike a
|
|
@@ -416,7 +236,7 @@ function resolveStaticFallbackPath(root, index, exclude, method, pathname, accep
|
|
|
416
236
|
*
|
|
417
237
|
* @param child - The absolute on-disk path to test
|
|
418
238
|
* @param parent - The absolute on-disk directory it must lie under
|
|
419
|
-
* @returns
|
|
239
|
+
* @returns True if `child` equals `parent` or resolves inside it; false otherwise
|
|
420
240
|
*
|
|
421
241
|
* @example
|
|
422
242
|
* ```ts
|
|
@@ -430,14 +250,14 @@ function isContainedPath(child, parent) {
|
|
|
430
250
|
return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
431
251
|
}
|
|
432
252
|
/**
|
|
433
|
-
*
|
|
434
|
-
* when it cannot — the traversal guard,
|
|
435
|
-
*
|
|
253
|
+
* Resolves a request pathname to an on-disk path under `root`, or `undefined`
|
|
254
|
+
* when it cannot — the traversal guard, whose algorithm and order are exact:
|
|
255
|
+
* strip `prefix` on a segment boundary → `decodeURIComponent` (a
|
|
436
256
|
* malformed escape refuses, never throws) → reject a NUL byte → strip the
|
|
437
|
-
* leading path separator
|
|
438
|
-
* genuine climbing segment) → `normalize` → refuse any Windows
|
|
439
|
-
* device-name segment ({@link isReservedDeviceName}) → `resolve` and
|
|
440
|
-
* the result under `root`.
|
|
257
|
+
* leading path separator first (so a leading `..` survives `normalize` as a
|
|
258
|
+
* genuine climbing segment) → `normalize` → refuse any Windows
|
|
259
|
+
* reserved-device-name segment ({@link isReservedDeviceName}) → `resolve` and
|
|
260
|
+
* require the result under `root`.
|
|
441
261
|
*
|
|
442
262
|
* @param root - The absolute root directory every result must resolve under
|
|
443
263
|
* @param prefix - An optional URL path prefix stripped on a segment boundary
|
|
@@ -469,20 +289,50 @@ function resolveStaticPath(root, prefix, pathname) {
|
|
|
469
289
|
const segments = normalized.split(/[/\\]+/).filter((segment) => segment.length > 0);
|
|
470
290
|
for (const segment of segments) if (isReservedDeviceName(segment)) return void 0;
|
|
471
291
|
const resolved = resolve(root, normalized);
|
|
472
|
-
|
|
292
|
+
return isContainedPath(resolved, root) ? resolved : void 0;
|
|
473
293
|
}
|
|
474
294
|
/**
|
|
475
|
-
*
|
|
295
|
+
* Canonicalizes `candidate` and returns it only when it lies inside `rootReal`
|
|
296
|
+
* — the shared realpath-then-contain step `createStatic` applies to a
|
|
297
|
+
* directory index and to its SPA shell.
|
|
298
|
+
*
|
|
299
|
+
* @remarks
|
|
300
|
+
* Total: a `realpath` failure (a dangling symlink, a missing file, a
|
|
301
|
+
* permission refusal) and an escape from `rootReal` both resolve `undefined`,
|
|
302
|
+
* so a caller that treats those two outcomes identically needs no `try`. A
|
|
303
|
+
* caller that must tell them apart keeps its own explicit branch instead.
|
|
304
|
+
*
|
|
305
|
+
* @param candidate - The on-disk path to canonicalize
|
|
306
|
+
* @param rootReal - The already-canonical root the result must lie under
|
|
307
|
+
* @returns The canonical path inside `rootReal`, or `undefined`
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```ts
|
|
311
|
+
* await resolveContainedRealPath('/srv/public/index.html', '/srv/public')
|
|
312
|
+
* // '/srv/public/index.html'
|
|
313
|
+
* ```
|
|
314
|
+
*/
|
|
315
|
+
async function resolveContainedRealPath(candidate, rootReal) {
|
|
316
|
+
let real;
|
|
317
|
+
try {
|
|
318
|
+
real = await realpath(candidate);
|
|
319
|
+
} catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
return isContainedPath(real, rootReal) ? real : void 0;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Checks whether a path segment is a Windows reserved device name (CVE-2025-27210).
|
|
476
326
|
*
|
|
477
327
|
* @remarks
|
|
478
328
|
* Normalizes superscript digits (`¹²³` → `123`) first, strips trailing dots
|
|
479
|
-
* and spaces (Windows drops them), takes the
|
|
329
|
+
* and spaces (Windows drops them), takes the stem before the first `.`,
|
|
480
330
|
* upper-cases it, and tests it against {@link RESERVED_DEVICE_NAMES}
|
|
481
331
|
* (`CON PRN AUX NUL COM1-9 LPT1-9`) — an exact-stem match only, so
|
|
482
332
|
* `console.js` and `nullable.css` are never flagged.
|
|
483
333
|
*
|
|
484
334
|
* @param segment - One path segment (no separators)
|
|
485
|
-
* @returns
|
|
335
|
+
* @returns True if `segment` names a reserved device; false otherwise
|
|
486
336
|
*
|
|
487
337
|
* @example
|
|
488
338
|
* ```ts
|
|
@@ -497,11 +347,11 @@ function isReservedDeviceName(segment) {
|
|
|
497
347
|
return RESERVED_DEVICE_NAMES.has(stem.toUpperCase());
|
|
498
348
|
}
|
|
499
349
|
/**
|
|
500
|
-
*
|
|
350
|
+
* Checks whether a relative path (already resolved under a static root) has any
|
|
501
351
|
* segment starting with `.` — a dotfile or dot-directory.
|
|
502
352
|
*
|
|
503
353
|
* @param relativePath - A path relative to the static root
|
|
504
|
-
* @returns
|
|
354
|
+
* @returns True if any segment starts with `.`; false otherwise
|
|
505
355
|
*
|
|
506
356
|
* @example
|
|
507
357
|
* ```ts
|
|
@@ -513,7 +363,7 @@ function isDotfilePath(relativePath) {
|
|
|
513
363
|
return relativePath.split(/[/\\]+/).some((segment) => segment.startsWith("."));
|
|
514
364
|
}
|
|
515
365
|
/**
|
|
516
|
-
*
|
|
366
|
+
* Looks up the MIME type for a static file path by its extension.
|
|
517
367
|
*
|
|
518
368
|
* @param pathname - The file's path (only its extension is read)
|
|
519
369
|
* @returns The mapped MIME type, or {@link DEFAULT_CONTENT_TYPE} when unknown
|
|
@@ -527,7 +377,7 @@ function lookupContentType(pathname) {
|
|
|
527
377
|
return EXTENSION_TYPES[extname(pathname).toLowerCase()] ?? "application/octet-stream";
|
|
528
378
|
}
|
|
529
379
|
/**
|
|
530
|
-
*
|
|
380
|
+
* Computes a static file's weak ETag from its size and modification time.
|
|
531
381
|
*
|
|
532
382
|
* @param size - The file's byte size
|
|
533
383
|
* @param mtimeMs - The file's modification time in milliseconds
|
|
@@ -542,7 +392,7 @@ function computeFileETag(size, mtimeMs) {
|
|
|
542
392
|
return `W/"${size}-${Math.floor(mtimeMs)}"`;
|
|
543
393
|
}
|
|
544
394
|
/**
|
|
545
|
-
*
|
|
395
|
+
* Compresses response bytes with Node's guaranteed zlib gzip/deflate codecs.
|
|
546
396
|
*
|
|
547
397
|
* @param bytes - The uncompressed response bytes
|
|
548
398
|
* @param encoding - The negotiated actionable coding
|
|
@@ -559,10 +409,16 @@ async function compressNodeBytes(bytes, encoding) {
|
|
|
559
409
|
return Uint8Array.from(compressed);
|
|
560
410
|
}
|
|
561
411
|
/**
|
|
562
|
-
*
|
|
563
|
-
* table (jpeg, png, gif87a/89a, webp, pdf, zip)
|
|
564
|
-
*
|
|
565
|
-
*
|
|
412
|
+
* Sniffs a MIME type from a file's leading bytes against a small magic-byte
|
|
413
|
+
* table (jpeg, png, gif87a/89a, webp, pdf, zip).
|
|
414
|
+
*
|
|
415
|
+
* @remarks
|
|
416
|
+
* `createMultipart`'s `allowed` check reads this signal alone and never the
|
|
417
|
+
* declared `Content-Type`, so a file whose bytes match no signature can never
|
|
418
|
+
* be placed on an `allowed` list. The record's `mime` field is a different
|
|
419
|
+
* question: it falls back to the declared `Content-Type` (then to
|
|
420
|
+
* {@link DEFAULT_CONTENT_TYPE}) when nothing sniffs, and its `validated` flag
|
|
421
|
+
* reports whether the sniffed and declared types agreed.
|
|
566
422
|
*
|
|
567
423
|
* @param head - The file's first bytes (16 is sufficient for every signature)
|
|
568
424
|
* @returns The detected MIME type, or `undefined` when no signature matches
|
|
@@ -640,12 +496,12 @@ function detectMIME(head) {
|
|
|
640
496
|
])) return "application/zip";
|
|
641
497
|
}
|
|
642
498
|
/**
|
|
643
|
-
*
|
|
499
|
+
* Checks whether `bytes` contains `signature` at the requested offset.
|
|
644
500
|
*
|
|
645
501
|
* @param bytes - The bytes to inspect
|
|
646
502
|
* @param signature - The exact byte sequence to match
|
|
647
503
|
* @param offset - The starting byte offset, defaulting to zero
|
|
648
|
-
* @returns
|
|
504
|
+
* @returns True if the complete signature matches; false otherwise
|
|
649
505
|
*
|
|
650
506
|
* @example
|
|
651
507
|
* ```ts
|
|
@@ -658,7 +514,7 @@ function matchesBytes(bytes, signature, offset = 0) {
|
|
|
658
514
|
return true;
|
|
659
515
|
}
|
|
660
516
|
/**
|
|
661
|
-
*
|
|
517
|
+
* Extracts the `boundary` parameter from a `Content-Type` header, or
|
|
662
518
|
* `undefined` when the request is not `multipart/form-data`.
|
|
663
519
|
*
|
|
664
520
|
* @param contentType - The request's `Content-Type` header value, if present
|
|
@@ -667,11 +523,11 @@ function matchesBytes(bytes, signature, offset = 0) {
|
|
|
667
523
|
*
|
|
668
524
|
* @example
|
|
669
525
|
* ```ts
|
|
670
|
-
*
|
|
671
|
-
*
|
|
526
|
+
* extractMultipartBoundary('multipart/form-data; boundary=abc123') // 'abc123'
|
|
527
|
+
* extractMultipartBoundary('application/json') // undefined
|
|
672
528
|
* ```
|
|
673
529
|
*/
|
|
674
|
-
function
|
|
530
|
+
function extractMultipartBoundary(contentType) {
|
|
675
531
|
if (contentType === null) return void 0;
|
|
676
532
|
const [type, ...params] = contentType.split(";").map((part) => part.trim());
|
|
677
533
|
if (type === void 0 || type.toLowerCase() !== "multipart/form-data") return void 0;
|
|
@@ -685,56 +541,49 @@ function multipartBoundary(contentType) {
|
|
|
685
541
|
}
|
|
686
542
|
}
|
|
687
543
|
/**
|
|
688
|
-
*
|
|
689
|
-
* every documented default.
|
|
544
|
+
* Resolves `createMultipart`'s effective {@link MultipartLimits}, applying
|
|
545
|
+
* every documented default to an omitted leaf.
|
|
690
546
|
*
|
|
691
547
|
* @param limits - The caller's partial limits
|
|
692
548
|
* @returns The fully-resolved limits
|
|
693
|
-
*/
|
|
694
|
-
function resolveMultipartLimits(limits) {
|
|
695
|
-
return {
|
|
696
|
-
file: limits?.file ?? 10485760,
|
|
697
|
-
files: limits?.files ?? 10,
|
|
698
|
-
field: limits?.field ?? 65536,
|
|
699
|
-
fields: limits?.fields ?? 100,
|
|
700
|
-
total: limits?.total ?? 52428800
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* Resolve `parseMultipartRequest`'s default staging directory when the
|
|
705
|
-
* caller did not configure one — a process-owned directory created ONCE
|
|
706
|
-
* (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
|
|
707
|
-
* locked to mode `0o700`.
|
|
708
|
-
*
|
|
709
|
-
* @returns The absolute path of the process-owned staging directory
|
|
710
549
|
*
|
|
711
550
|
* @example
|
|
712
551
|
* ```ts
|
|
713
|
-
*
|
|
552
|
+
* resolveMultipartLimits({ file: { size: 1_048_576 } })
|
|
714
553
|
* ```
|
|
715
554
|
*/
|
|
716
|
-
function
|
|
717
|
-
return
|
|
555
|
+
function resolveMultipartLimits(limits) {
|
|
556
|
+
return {
|
|
557
|
+
file: {
|
|
558
|
+
size: limits?.file?.size ?? 10485760,
|
|
559
|
+
count: limits?.file?.count ?? 10
|
|
560
|
+
},
|
|
561
|
+
field: {
|
|
562
|
+
size: limits?.field?.size ?? 65536,
|
|
563
|
+
count: limits?.field?.count ?? 100
|
|
564
|
+
},
|
|
565
|
+
total: limits?.total ?? 52428800
|
|
566
|
+
};
|
|
718
567
|
}
|
|
719
568
|
/**
|
|
720
|
-
*
|
|
569
|
+
* Parses one multipart part's raw header block into its `name` (from
|
|
721
570
|
* `Content-Disposition`), optional `filename`, and optional `Content-Type`.
|
|
722
571
|
*
|
|
723
572
|
* @param block - The raw header block for one multipart part (before the
|
|
724
573
|
* terminating blank line)
|
|
725
|
-
* @returns The parsed `name`, `filename`, and `
|
|
726
|
-
*
|
|
574
|
+
* @returns The parsed `name`, `filename`, and `mime` (each `undefined` when
|
|
575
|
+
* absent)
|
|
727
576
|
*
|
|
728
577
|
* @example
|
|
729
578
|
* ```ts
|
|
730
579
|
* parsePartHeaders('Content-Disposition: form-data; name="title"')
|
|
731
|
-
* // { name: 'title', filename: undefined,
|
|
580
|
+
* // { name: 'title', filename: undefined, mime: undefined }
|
|
732
581
|
* ```
|
|
733
582
|
*/
|
|
734
583
|
function parsePartHeaders(block) {
|
|
735
584
|
let name;
|
|
736
585
|
let filename;
|
|
737
|
-
let
|
|
586
|
+
let mime;
|
|
738
587
|
for (const line of block.split("\r\n")) {
|
|
739
588
|
const colon = line.indexOf(":");
|
|
740
589
|
if (colon === -1) continue;
|
|
@@ -745,69 +594,19 @@ function parsePartHeaders(block) {
|
|
|
745
594
|
const filenameMatch = /;\s*filename="([^"]*)"/.exec(value);
|
|
746
595
|
if (nameMatch !== null) name = nameMatch[1];
|
|
747
596
|
if (filenameMatch !== null) filename = filenameMatch[1];
|
|
748
|
-
} else if (key === "content-type")
|
|
597
|
+
} else if (key === "content-type") mime = value;
|
|
749
598
|
}
|
|
750
599
|
return {
|
|
751
600
|
name,
|
|
752
601
|
filename,
|
|
753
|
-
|
|
602
|
+
mime
|
|
754
603
|
};
|
|
755
604
|
}
|
|
756
605
|
/**
|
|
757
|
-
*
|
|
758
|
-
* the mid-stream state machine `createMultipart` drives (PROPOSAL §4.15).
|
|
759
|
-
*
|
|
760
|
-
* @remarks
|
|
761
|
-
* Reads `request.body` chunk by chunk via its `ReadableStream` reader —
|
|
762
|
-
* NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
|
|
763
|
-
* the instant it is exceeded (reading stops, every already-staged temp file
|
|
764
|
-
* is deleted, throws {@link MultipartError} with reason `'limit'`). Each file
|
|
765
|
-
* part streams to `join(directory, randomUUID())` — the client's declared
|
|
766
|
-
* filename is METADATA ONLY, never a path component. A field OR file part
|
|
767
|
-
* named `__proto__` / `constructor` / `prototype` is silently skipped and
|
|
768
|
-
* never keyed onto the returned {@link MultipartBody} (a skipped file's
|
|
769
|
-
* staged temp file is unlinked immediately, since it can never be
|
|
770
|
-
* referenced). A file part with an empty declared filename (`filename=""`)
|
|
771
|
-
* AND a zero-byte body — the browser convention for an unselected optional
|
|
772
|
-
* `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
|
|
773
|
-
* never counted against the `files` limit, and it never runs the `allowed`
|
|
774
|
-
* check. A malformed
|
|
775
|
-
* structure (missing/unterminated boundary, nameless part, an oversized
|
|
776
|
-
* header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
|
|
777
|
-
* the first boundary) throws with reason `'malformed'`. A file is accepted
|
|
778
|
-
* against the configured `allowed` MIME list iff its SNIFFED bytes detect a
|
|
779
|
-
* type present in the list — sniff-authoritative, independent of whether the
|
|
780
|
-
* declared `Content-Type` matches (that agreement is exposed separately as
|
|
781
|
-
* `validated`); otherwise throws with reason `'rejected'`. A
|
|
782
|
-
* request abort mid-upload triggers the same fail-closed cleanup as a limit
|
|
783
|
-
* breach. Returns `undefined` for a non-multipart request (untouched).
|
|
784
|
-
*
|
|
785
|
-
* @param request - The incoming multipart request
|
|
786
|
-
* @param options - See {@link MultipartOptions}
|
|
787
|
-
* @returns The parsed {@link MultipartBody}, or `undefined` when the request
|
|
788
|
-
* is not `multipart/form-data`
|
|
789
|
-
* @throws {MultipartError} On any limit breach, malformed structure, or
|
|
790
|
-
* rejected file type
|
|
791
|
-
*
|
|
792
|
-
* @example
|
|
793
|
-
* ```ts
|
|
794
|
-
* const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
|
|
795
|
-
* ```
|
|
796
|
-
*/
|
|
797
|
-
async function parseMultipartRequest(request, options = {}) {
|
|
798
|
-
const boundary = multipartBoundary(request.headers.get("content-type"));
|
|
799
|
-
if (boundary === void 0) return void 0;
|
|
800
|
-
if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
|
|
801
|
-
const limits = resolveMultipartLimits(options.limits);
|
|
802
|
-
const allowed = options.allowed;
|
|
803
|
-
const directory = options.directory ?? await resolveDefaultDirectory();
|
|
804
|
-
return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Build a frozen {@link UploadedFileInterface} record.
|
|
606
|
+
* Builds a frozen {@link UploadedFile} record.
|
|
808
607
|
*
|
|
809
608
|
* @param input - Every field of the record
|
|
810
|
-
* @returns A frozen {@link
|
|
609
|
+
* @returns A frozen {@link UploadedFile}
|
|
811
610
|
*
|
|
812
611
|
* @example
|
|
813
612
|
* ```ts
|
|
@@ -818,7 +617,7 @@ function createUploadedFile(input) {
|
|
|
818
617
|
return Object.freeze({ ...input });
|
|
819
618
|
}
|
|
820
619
|
/**
|
|
821
|
-
*
|
|
620
|
+
* Attempts to unlink every still-`'staged'` file in a parsed
|
|
822
621
|
* {@link MultipartBody} — the fail-closed cleanup `createMultipart` runs when
|
|
823
622
|
* its downstream handler throws, mirroring `parseMultipartRequest`'s own
|
|
824
623
|
* cleanup pattern (a missing file is already gone; failures are swallowed).
|
|
@@ -840,35 +639,34 @@ async function unlinkStagedFiles(body) {
|
|
|
840
639
|
}
|
|
841
640
|
}
|
|
842
641
|
/**
|
|
843
|
-
*
|
|
642
|
+
* Adapts a `node:fs` read stream over a file path (or an already-open
|
|
844
643
|
* `FileHandle`) into a DOM-compatible `ReadableStream<Uint8Array>` — the
|
|
845
644
|
* single shared node↔web stream bridge every static-file and uploaded-file
|
|
846
645
|
* response body routes through.
|
|
847
646
|
*
|
|
848
647
|
* @remarks
|
|
849
|
-
*
|
|
648
|
+
* Pull-driven, not push-driven: the underlying node stream's async iterator
|
|
850
649
|
* is only advanced (`iterator.next()`) from inside `pull(controller)`, which
|
|
851
650
|
* the web `ReadableStream` invokes exactly when its internal queue has room
|
|
852
651
|
* for more data. Exactly one disk chunk is read and enqueued per `pull` —
|
|
853
652
|
* never more — so a slow or stalled consumer (a stalled HTTP connection)
|
|
854
|
-
*
|
|
653
|
+
* stops triggering `pull` calls and the source stops reading ahead;
|
|
855
654
|
* this is genuine consumer backpressure, not the "naturally backpressured"
|
|
856
655
|
* `for await`/`enqueue` pattern (which does not block on a slow consumer at
|
|
857
|
-
* all,
|
|
656
|
+
* all, because `enqueue` returns synchronously). The controller is closed on
|
|
858
657
|
* iterator completion and errored (never thrown into the process) on a
|
|
859
|
-
* mid-stream read failure. Cancelling the returned `ReadableStream` (
|
|
860
|
-
* consumer aborts the response) calls the iterator's `return()`, which
|
|
658
|
+
* mid-stream read failure. Cancelling the returned `ReadableStream` (for
|
|
659
|
+
* example the consumer aborts the response) calls the iterator's `return()`, which
|
|
861
660
|
* destroys the underlying node read stream so the file descriptor is
|
|
862
|
-
* released. When `
|
|
661
|
+
* released. When `source` is a `FileHandle`, `FileHandle.createReadStream`'s
|
|
863
662
|
* default `autoClose` closes the handle on every terminal path (end, error,
|
|
864
|
-
* or `destroy()`
|
|
865
|
-
* separate `handle.close()` for a
|
|
663
|
+
* or `destroy()` through the iterator's `return()`) — the caller never needs a
|
|
664
|
+
* separate `handle.close()` for a `FileHandle` passed as `source`.
|
|
866
665
|
*
|
|
867
666
|
* @param source - The absolute on-disk file path to stream, or an already-open
|
|
868
|
-
* `FileHandle` (
|
|
667
|
+
* `FileHandle` (for example one already `fstat`'d so the served bytes match the
|
|
869
668
|
* headers computed from that same `fstat`)
|
|
870
|
-
* @param range - An optional inclusive byte range
|
|
871
|
-
* 0-indexed and inclusive, matching `node:fs`'s `createReadStream` options)
|
|
669
|
+
* @param range - An optional inclusive byte range; see {@link ByteRange}
|
|
872
670
|
* @returns A `ReadableStream<Uint8Array>` valid as a fetch `BodyInit`
|
|
873
671
|
*
|
|
874
672
|
* @example
|
|
@@ -909,9 +707,9 @@ function streamFile(source, range) {
|
|
|
909
707
|
});
|
|
910
708
|
}
|
|
911
709
|
/**
|
|
912
|
-
*
|
|
710
|
+
* Opens a staged/moved uploaded file as a web `ReadableStream`.
|
|
913
711
|
*
|
|
914
|
-
* @param file - The {@link
|
|
712
|
+
* @param file - The {@link UploadedFile} record to stream
|
|
915
713
|
* @returns A `ReadableStream<Uint8Array>` over the file's current on-disk path
|
|
916
714
|
*
|
|
917
715
|
* @example
|
|
@@ -923,9 +721,9 @@ function streamUploadedFile(file) {
|
|
|
923
721
|
return streamFile(file.path);
|
|
924
722
|
}
|
|
925
723
|
/**
|
|
926
|
-
*
|
|
724
|
+
* Reads a staged/moved uploaded file's full contents into memory.
|
|
927
725
|
*
|
|
928
|
-
* @param file - The {@link
|
|
726
|
+
* @param file - The {@link UploadedFile} record to read
|
|
929
727
|
* @returns The file's bytes
|
|
930
728
|
*
|
|
931
729
|
* @example
|
|
@@ -937,16 +735,18 @@ async function readUploadedFile(file) {
|
|
|
937
735
|
return readFile(file.path);
|
|
938
736
|
}
|
|
939
737
|
/**
|
|
940
|
-
*
|
|
738
|
+
* Moves a staged uploaded file to its final `destination`.
|
|
941
739
|
*
|
|
942
740
|
* @remarks
|
|
943
741
|
* Attempts a `rename` first; on a cross-device error (`EXDEV`) falls back to
|
|
944
742
|
* `copyFile` + `unlink`. Returns a new frozen record with `status: 'moved'`
|
|
945
|
-
* and `path: destination` — the input record is never mutated.
|
|
743
|
+
* and `path: destination` — the input record is never mutated. The suite
|
|
744
|
+
* drives the `EXDEV` fallback only on a host whose device probe finds a
|
|
745
|
+
* second filesystem, so a single-device host leaves that branch unproven.
|
|
946
746
|
*
|
|
947
|
-
* @param file - The {@link
|
|
747
|
+
* @param file - The {@link UploadedFile} record to move
|
|
948
748
|
* @param destination - The final on-disk path
|
|
949
|
-
* @returns A new {@link
|
|
749
|
+
* @returns A new {@link UploadedFile} record reflecting the move
|
|
950
750
|
*
|
|
951
751
|
* @example
|
|
952
752
|
* ```ts
|
|
@@ -957,7 +757,7 @@ async function moveUploadedFile(file, destination) {
|
|
|
957
757
|
try {
|
|
958
758
|
await rename(file.path, destination);
|
|
959
759
|
} catch (error) {
|
|
960
|
-
if (
|
|
760
|
+
if (isError(error) && "code" in error && error.code === "EXDEV") {
|
|
961
761
|
await copyFile(file.path, destination);
|
|
962
762
|
await unlink(file.path);
|
|
963
763
|
} else throw error;
|
|
@@ -973,9 +773,267 @@ async function moveUploadedFile(file, destination) {
|
|
|
973
773
|
});
|
|
974
774
|
}
|
|
975
775
|
//#endregion
|
|
776
|
+
//#region src/server/MultipartParser.ts
|
|
777
|
+
var MultipartParser = class MultipartParser {
|
|
778
|
+
static #defaultDirectory;
|
|
779
|
+
#reader;
|
|
780
|
+
#signal;
|
|
781
|
+
#abort;
|
|
782
|
+
#boundary;
|
|
783
|
+
#limits;
|
|
784
|
+
#allowed;
|
|
785
|
+
#directory;
|
|
786
|
+
#staged = [];
|
|
787
|
+
#files = Object.create(null);
|
|
788
|
+
#fields = Object.create(null);
|
|
789
|
+
#buffer = Buffer.alloc(0);
|
|
790
|
+
#ended = false;
|
|
791
|
+
#fileCount = 0;
|
|
792
|
+
#fieldCount = 0;
|
|
793
|
+
#totalBytes = 0;
|
|
794
|
+
constructor(stream, signal, boundary, limits, allowed, directory) {
|
|
795
|
+
this.#reader = stream.getReader();
|
|
796
|
+
this.#signal = signal;
|
|
797
|
+
this.#abort = this.#wakeReader.bind(this);
|
|
798
|
+
this.#boundary = boundary;
|
|
799
|
+
this.#limits = limits;
|
|
800
|
+
this.#allowed = allowed;
|
|
801
|
+
this.#directory = directory;
|
|
802
|
+
}
|
|
803
|
+
static directory() {
|
|
804
|
+
if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
|
|
805
|
+
return MultipartParser.#defaultDirectory;
|
|
806
|
+
}
|
|
807
|
+
async parse() {
|
|
808
|
+
this.#signal.addEventListener("abort", this.#abort, { once: true });
|
|
809
|
+
try {
|
|
810
|
+
const openMarker = Buffer.from(`--${this.#boundary}`);
|
|
811
|
+
let preambleScanned = 0;
|
|
812
|
+
let index = this.#buffer.indexOf(openMarker);
|
|
813
|
+
while (index === -1) {
|
|
814
|
+
const carry = openMarker.length - 1;
|
|
815
|
+
if (this.#buffer.length > carry) {
|
|
816
|
+
const drop = this.#buffer.length - carry;
|
|
817
|
+
preambleScanned += drop;
|
|
818
|
+
if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
819
|
+
this.#buffer = this.#buffer.subarray(drop);
|
|
820
|
+
}
|
|
821
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
|
|
822
|
+
index = this.#buffer.indexOf(openMarker);
|
|
823
|
+
}
|
|
824
|
+
if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
825
|
+
this.#buffer = this.#buffer.subarray(index + openMarker.length);
|
|
826
|
+
for (;;) {
|
|
827
|
+
while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
828
|
+
if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
|
|
829
|
+
if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
|
|
830
|
+
this.#buffer = this.#buffer.subarray(2);
|
|
831
|
+
let headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
832
|
+
while (headerEnd === -1) {
|
|
833
|
+
if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
834
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
|
|
835
|
+
headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
836
|
+
}
|
|
837
|
+
if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
838
|
+
const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
|
|
839
|
+
this.#buffer = this.#buffer.subarray(headerEnd + 4);
|
|
840
|
+
const { name, filename, mime } = parsePartHeaders(headerBlock);
|
|
841
|
+
if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
|
|
842
|
+
const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
|
|
843
|
+
if (filename !== void 0) await this.#consumeFile(name, filename, mime, partDelimiter);
|
|
844
|
+
else await this.#consumeField(name, partDelimiter);
|
|
845
|
+
while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
846
|
+
this.#buffer = this.#buffer.subarray(openMarker.length);
|
|
847
|
+
}
|
|
848
|
+
} catch (error) {
|
|
849
|
+
await this.#cleanup();
|
|
850
|
+
await this.#reader.cancel().catch(() => {});
|
|
851
|
+
throw error;
|
|
852
|
+
} finally {
|
|
853
|
+
this.#signal.removeEventListener("abort", this.#abort);
|
|
854
|
+
if (!this.#ended) await this.#reader.cancel().catch(() => {});
|
|
855
|
+
}
|
|
856
|
+
const files = Object.create(null);
|
|
857
|
+
for (const [field, records] of Object.entries(this.#files)) files[field] = Object.freeze([...records]);
|
|
858
|
+
const fields = Object.assign(Object.create(null), this.#fields);
|
|
859
|
+
return {
|
|
860
|
+
files: Object.freeze(files),
|
|
861
|
+
fields: Object.freeze(fields)
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
async #consumeFile(name, filename, mime, delimiter) {
|
|
865
|
+
if (filename !== "") {
|
|
866
|
+
this.#fileCount += 1;
|
|
867
|
+
if (this.#fileCount > this.#limits.file.count) throw new MultipartError("limit", "too many multipart files");
|
|
868
|
+
}
|
|
869
|
+
const path = join(this.#directory, randomUUID());
|
|
870
|
+
this.#staged.push(path);
|
|
871
|
+
const handle = await open(path, "w", 384);
|
|
872
|
+
let size = 0;
|
|
873
|
+
let head = Buffer.alloc(0);
|
|
874
|
+
try {
|
|
875
|
+
await this.#scan(delimiter, "unterminated multipart file part", async (chunk) => {
|
|
876
|
+
size += chunk.length;
|
|
877
|
+
if (size > this.#limits.file.size) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
878
|
+
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
879
|
+
await handle.write(chunk);
|
|
880
|
+
});
|
|
881
|
+
} finally {
|
|
882
|
+
await handle.close();
|
|
883
|
+
}
|
|
884
|
+
if (filename === "" && size === 0) {
|
|
885
|
+
await this.#discard(path);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (filename === "") {
|
|
889
|
+
this.#fileCount += 1;
|
|
890
|
+
if (this.#fileCount > this.#limits.file.count) throw new MultipartError("limit", "too many multipart files");
|
|
891
|
+
}
|
|
892
|
+
const detected = detectMIME(head);
|
|
893
|
+
const declared = mime ?? "application/octet-stream";
|
|
894
|
+
const validated = detected !== void 0 && detected === declared;
|
|
895
|
+
if (this.#allowed !== void 0) {
|
|
896
|
+
if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
|
|
897
|
+
}
|
|
898
|
+
if (isDangerousKey(name)) {
|
|
899
|
+
await this.#discard(path);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const record = createUploadedFile({
|
|
903
|
+
field: name,
|
|
904
|
+
name: filename,
|
|
905
|
+
size,
|
|
906
|
+
mime: detected ?? declared,
|
|
907
|
+
validated,
|
|
908
|
+
status: "staged",
|
|
909
|
+
path
|
|
910
|
+
});
|
|
911
|
+
const existing = this.#files[name];
|
|
912
|
+
if (existing === void 0) this.#files[name] = [record];
|
|
913
|
+
else existing.push(record);
|
|
914
|
+
}
|
|
915
|
+
async #consumeField(name, delimiter) {
|
|
916
|
+
this.#fieldCount += 1;
|
|
917
|
+
if (this.#fieldCount > this.#limits.field.count) throw new MultipartError("limit", "too many multipart fields");
|
|
918
|
+
let value = Buffer.alloc(0);
|
|
919
|
+
await this.#scan(delimiter, "unterminated multipart field part", (chunk) => {
|
|
920
|
+
value = Buffer.concat([value, chunk]);
|
|
921
|
+
if (value.length > this.#limits.field.size) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
922
|
+
});
|
|
923
|
+
if (!isDangerousKey(name)) this.#fields[name] = value.toString("utf8");
|
|
924
|
+
}
|
|
925
|
+
async #scan(delimiter, unterminated, sink) {
|
|
926
|
+
for (;;) {
|
|
927
|
+
const boundaryIndex = this.#buffer.indexOf(delimiter);
|
|
928
|
+
if (boundaryIndex === -1) {
|
|
929
|
+
const safeLength = Math.max(0, this.#buffer.length - (delimiter.length - 1));
|
|
930
|
+
if (safeLength > 0) {
|
|
931
|
+
await sink(this.#buffer.subarray(0, safeLength));
|
|
932
|
+
this.#buffer = this.#buffer.subarray(safeLength);
|
|
933
|
+
}
|
|
934
|
+
if (!await this.#pull()) throw new MultipartError("malformed", unterminated);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
await sink(this.#buffer.subarray(0, boundaryIndex));
|
|
938
|
+
this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async #discard(path) {
|
|
943
|
+
try {
|
|
944
|
+
await unlink(path);
|
|
945
|
+
} catch {}
|
|
946
|
+
const index = this.#staged.indexOf(path);
|
|
947
|
+
if (index !== -1) this.#staged.splice(index, 1);
|
|
948
|
+
}
|
|
949
|
+
static async #createDirectory() {
|
|
950
|
+
const path = await mkdtemp(join(tmpdir(), "orkestrel-multipart-"));
|
|
951
|
+
await chmod(path, 448);
|
|
952
|
+
return path;
|
|
953
|
+
}
|
|
954
|
+
async #cleanup() {
|
|
955
|
+
for (const path of this.#staged) try {
|
|
956
|
+
await unlink(path);
|
|
957
|
+
} catch {}
|
|
958
|
+
}
|
|
959
|
+
async #pull() {
|
|
960
|
+
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
961
|
+
if (this.#ended) return false;
|
|
962
|
+
const { done, value } = await this.#reader.read();
|
|
963
|
+
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
964
|
+
if (done) {
|
|
965
|
+
this.#ended = true;
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
this.#totalBytes += value.byteLength;
|
|
969
|
+
if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
|
|
970
|
+
this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
|
|
971
|
+
return true;
|
|
972
|
+
}
|
|
973
|
+
#wakeReader() {
|
|
974
|
+
this.#reader.cancel().catch(() => {});
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
//#endregion
|
|
978
|
+
//#region src/server/parsers.ts
|
|
979
|
+
/**
|
|
980
|
+
* Stream-parses a `multipart/form-data` request into its files and fields —
|
|
981
|
+
* the mid-stream state machine `createMultipart` drives.
|
|
982
|
+
*
|
|
983
|
+
* @remarks
|
|
984
|
+
* Reads `request.body` chunk by chunk through its `ReadableStream` reader —
|
|
985
|
+
* never buffers the whole body — enforcing every {@link MultipartLimits} cap
|
|
986
|
+
* the instant it is exceeded (reading stops, every already-staged temp file
|
|
987
|
+
* is deleted, throws {@link MultipartError} with code `'limit'`). Each file
|
|
988
|
+
* part streams to `join(directory, randomUUID())` — the client's declared
|
|
989
|
+
* filename is metadata only, never a path component. A field or file part
|
|
990
|
+
* named `__proto__` / `constructor` / `prototype` is silently skipped and
|
|
991
|
+
* never keyed onto the returned {@link MultipartBody} (a skipped file's
|
|
992
|
+
* staged temp file is unlinked immediately, since it can never be
|
|
993
|
+
* referenced). A file part with an empty declared filename (`filename=""`)
|
|
994
|
+
* and a zero-byte body — the browser convention for an unselected optional
|
|
995
|
+
* `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
|
|
996
|
+
* never counted against the `file.count` limit, and it never runs the
|
|
997
|
+
* `allowed` check. A malformed
|
|
998
|
+
* structure (missing/unterminated boundary, nameless part, an oversized
|
|
999
|
+
* header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
|
|
1000
|
+
* the first boundary) throws with code `'malformed'`. A file is accepted
|
|
1001
|
+
* against the configured `allowed` MIME list exactly when its sniffed bytes detect a
|
|
1002
|
+
* type present in the list — sniff-authoritative, independent of whether the
|
|
1003
|
+
* declared `Content-Type` matches (that agreement is exposed separately as
|
|
1004
|
+
* `validated`); otherwise throws with code `'rejected'`. A
|
|
1005
|
+
* request abort mid-upload triggers the same fail-closed cleanup as a limit
|
|
1006
|
+
* breach. Returns `undefined` for a non-multipart request (untouched).
|
|
1007
|
+
*
|
|
1008
|
+
* Staging defaults to a process-owned directory created once (lazily,
|
|
1009
|
+
* memoized across calls) with `mkdtemp` under `os.tmpdir()` and locked to
|
|
1010
|
+
* mode `0o700`; `options.directory` overrides it.
|
|
1011
|
+
*
|
|
1012
|
+
* @param request - The incoming multipart request
|
|
1013
|
+
* @param options - See {@link MultipartOptions}
|
|
1014
|
+
* @returns The parsed {@link MultipartBody}, or `undefined` when the request
|
|
1015
|
+
* is not `multipart/form-data`
|
|
1016
|
+
* @throws {MultipartError} On any limit breach, malformed structure, or
|
|
1017
|
+
* rejected file type
|
|
1018
|
+
*
|
|
1019
|
+
* @example
|
|
1020
|
+
* ```ts
|
|
1021
|
+
* const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
|
|
1022
|
+
* ```
|
|
1023
|
+
*/
|
|
1024
|
+
async function parseMultipartRequest(request, options = {}) {
|
|
1025
|
+
const boundary = extractMultipartBoundary(request.headers.get("content-type"));
|
|
1026
|
+
if (boundary === void 0) return void 0;
|
|
1027
|
+
if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
|
|
1028
|
+
const limits = resolveMultipartLimits(options.limits);
|
|
1029
|
+
const allowed = options.allowed;
|
|
1030
|
+
const directory = options.directory ?? await MultipartParser.directory();
|
|
1031
|
+
return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
|
|
1032
|
+
}
|
|
1033
|
+
//#endregion
|
|
976
1034
|
//#region src/server/middlewares.ts
|
|
977
1035
|
/**
|
|
978
|
-
*
|
|
1036
|
+
* Serves validated in-memory assets with identity/Brotli negotiation.
|
|
979
1037
|
*
|
|
980
1038
|
* @remarks
|
|
981
1039
|
* Only `GET` and `HEAD` are served. `/` resolves to `index.html`. Every other
|
|
@@ -1068,23 +1126,23 @@ function createAssets(options) {
|
|
|
1068
1126
|
};
|
|
1069
1127
|
}
|
|
1070
1128
|
/**
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1129
|
+
* Serves static files from `options.root` over `node:fs` — the node-bound static-file
|
|
1130
|
+
* battery, answering conditional, ranged, and SPA-fallback requests.
|
|
1073
1131
|
*
|
|
1074
1132
|
* @remarks
|
|
1075
|
-
* Containment is enforced on
|
|
1133
|
+
* Containment is enforced on canonical paths, not merely the lexically
|
|
1076
1134
|
* resolved one: `options.root` is canonicalized once (memoized) and every
|
|
1077
1135
|
* request's candidate path is re-canonicalized (`fs.realpath`) before it is
|
|
1078
1136
|
* served, so a symlink whose target escapes `root` is refused (falls through
|
|
1079
1137
|
* to `next()`) even though the lexical path resolved inside `root`. A
|
|
1080
|
-
* symlink that resolves to a target still
|
|
1138
|
+
* symlink that resolves to a target still inside `root` is unaffected and
|
|
1081
1139
|
* still serves normally. A dangling symlink (`realpath` throws `ENOENT`) or
|
|
1082
1140
|
* any other `realpath` failure is treated as a miss — this battery never
|
|
1083
1141
|
* throws or 500s on a symlink surprise. On a streamed response (a 200 or 206
|
|
1084
1142
|
* that carries a file body), the open `FileHandle` is owned by the
|
|
1085
1143
|
* `Response` body and is released only once that body is fully read or
|
|
1086
1144
|
* cancelled — Node HTTP servers do this automatically when sending the
|
|
1087
|
-
* response, but a caller that holds an unread `Response` (
|
|
1145
|
+
* response, but a caller that holds an unread `Response` (for example in a test)
|
|
1088
1146
|
* must cancel its body to release the handle promptly.
|
|
1089
1147
|
*
|
|
1090
1148
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
@@ -1136,9 +1194,10 @@ function createStatic(options) {
|
|
|
1136
1194
|
resolvedPath = join(resolvedPath, index);
|
|
1137
1195
|
try {
|
|
1138
1196
|
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1197
|
+
const rootReal = await canonicalRootPromise;
|
|
1198
|
+
const indexReal = await resolveContainedRealPath(resolvedPath, rootReal);
|
|
1199
|
+
if (indexReal === void 0) fallbackNeeded = true;
|
|
1200
|
+
else resolvedPath = indexReal;
|
|
1142
1201
|
} catch {
|
|
1143
1202
|
fallbackNeeded = true;
|
|
1144
1203
|
}
|
|
@@ -1169,8 +1228,8 @@ function createStatic(options) {
|
|
|
1169
1228
|
let shellReal;
|
|
1170
1229
|
try {
|
|
1171
1230
|
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1172
|
-
const
|
|
1173
|
-
if (
|
|
1231
|
+
const candidate = await resolveContainedRealPath(shellPath, await canonicalRootPromise);
|
|
1232
|
+
if (candidate === void 0) return next();
|
|
1174
1233
|
shellReal = candidate;
|
|
1175
1234
|
} catch {
|
|
1176
1235
|
return next();
|
|
@@ -1181,16 +1240,20 @@ function createStatic(options) {
|
|
|
1181
1240
|
} catch {
|
|
1182
1241
|
return next();
|
|
1183
1242
|
}
|
|
1243
|
+
let shellInfo;
|
|
1184
1244
|
try {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
status: 200,
|
|
1188
|
-
headers: new Headers({ "content-type": lookupContentType(shellReal) })
|
|
1189
|
-
});
|
|
1190
|
-
} catch (error) {
|
|
1245
|
+
shellInfo = await shellHandle.stat();
|
|
1246
|
+
} catch {
|
|
1191
1247
|
await shellHandle.close().catch(() => {});
|
|
1192
|
-
|
|
1248
|
+
return next();
|
|
1249
|
+
}
|
|
1250
|
+
if (!shellInfo.isFile()) {
|
|
1251
|
+
await shellHandle.close().catch(() => {});
|
|
1252
|
+
return next();
|
|
1193
1253
|
}
|
|
1254
|
+
resolvedPath = shellReal;
|
|
1255
|
+
handle = shellHandle;
|
|
1256
|
+
info = shellInfo;
|
|
1194
1257
|
}
|
|
1195
1258
|
if (handle === void 0 || info === void 0) return next();
|
|
1196
1259
|
let streaming = false;
|
|
@@ -1257,9 +1320,9 @@ function createStatic(options) {
|
|
|
1257
1320
|
};
|
|
1258
1321
|
}
|
|
1259
1322
|
/**
|
|
1260
|
-
*
|
|
1323
|
+
* Parses a streamed `multipart/form-data` request body and stashes its
|
|
1261
1324
|
* {@link MultipartBody} on `context.state.multipart` — the node-bound
|
|
1262
|
-
* streaming multipart battery
|
|
1325
|
+
* streaming multipart battery.
|
|
1263
1326
|
*
|
|
1264
1327
|
* @remarks
|
|
1265
1328
|
* A non-multipart request passes through untouched. Consumes `request.body`
|
|
@@ -1268,7 +1331,7 @@ function createStatic(options) {
|
|
|
1268
1331
|
* {@link MultipartError} this battery's parser throws is re-thrown as an
|
|
1269
1332
|
* {@link HTTPError} carrying the same status/message, so `createBoundary`
|
|
1270
1333
|
* (or any HTTPError-aware renderer) maps it correctly without depending on
|
|
1271
|
-
* this node face's error type. Fail-closed on the
|
|
1334
|
+
* this node face's error type. Fail-closed on the downstream handler too: if
|
|
1272
1335
|
* `next()` throws, every still-`'staged'` uploaded file is unlinked
|
|
1273
1336
|
* (best-effort) before the error is re-thrown, so an unhandled downstream
|
|
1274
1337
|
* failure never leaks temp files. A normal return leaves staged files
|
|
@@ -1307,20 +1370,18 @@ function createMultipart(options = {}) {
|
|
|
1307
1370
|
};
|
|
1308
1371
|
}
|
|
1309
1372
|
/**
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
* face's `createCompression`, so the shared name is unambiguous per
|
|
1316
|
-
* consumer import path (ruling H).
|
|
1373
|
+
* Compresses response bodies through `node:zlib`, guaranteed on any Node runtime rather
|
|
1374
|
+
* than dependent on the WHATWG `CompressionStream` global. This battery is the
|
|
1375
|
+
* node-bound sibling of the core face's feature-detected `createCompression`, and it
|
|
1376
|
+
* ships from a separate package entry point (`@orkestrel/middleware/server`) so the
|
|
1377
|
+
* shared name is unambiguous per consumer import path.
|
|
1317
1378
|
*
|
|
1318
1379
|
* @remarks
|
|
1319
|
-
* Peer-type limitation
|
|
1380
|
+
* Peer-type limitation, the same one the core face carries: the shipped
|
|
1320
1381
|
* `@orkestrel/server` `Encoding` union is `'gzip' | 'deflate' | 'identity'`
|
|
1321
1382
|
* — it does not include `'br'`, so this battery cannot honestly type or
|
|
1322
1383
|
* negotiate a guaranteed brotli coding despite `node:zlib` shipping
|
|
1323
|
-
* `brotliCompress`. It guarantees `gzip`/`deflate`
|
|
1384
|
+
* `brotliCompress`. It guarantees `gzip`/`deflate` through `node:zlib` (never
|
|
1324
1385
|
* feature-detected — always available) and negotiates only those.
|
|
1325
1386
|
*
|
|
1326
1387
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
@@ -1341,7 +1402,7 @@ function createCompression(options) {
|
|
|
1341
1402
|
if (options?.filter !== void 0 && !isFunction(options.filter)) throw new TypeError("NodeCompressionOptions.filter must be a function when provided");
|
|
1342
1403
|
const threshold = options?.threshold ?? DEFAULT_COMPRESSION_THRESHOLD;
|
|
1343
1404
|
const filter = options?.filter;
|
|
1344
|
-
const encodings =
|
|
1405
|
+
const encodings = NODE_COMPRESSION_ENCODINGS;
|
|
1345
1406
|
return async (request, context, next) => {
|
|
1346
1407
|
const response = await next();
|
|
1347
1408
|
return compressResponse(request, context, response, {
|
|
@@ -1353,6 +1414,6 @@ function createCompression(options) {
|
|
|
1353
1414
|
};
|
|
1354
1415
|
}
|
|
1355
1416
|
//#endregion
|
|
1356
|
-
export { DEFAULT_CONTENT_TYPE,
|
|
1417
|
+
export { DEFAULT_CONTENT_TYPE, DEFAULT_MULTIPART_FIELD_COUNT, DEFAULT_MULTIPART_FIELD_SIZE, DEFAULT_MULTIPART_FILE_COUNT, DEFAULT_MULTIPART_FILE_SIZE, DEFAULT_MULTIPART_TOTAL, DEFAULT_STATIC_DOTFILES, DEFAULT_STATIC_FALLBACK_EXCLUDE, DEFAULT_STATIC_INDEX, EXTENSION_TYPES, MULTIPART_ERROR_BRAND, MULTIPART_MAX_HEADER_BLOCK, MULTIPART_MAX_PREAMBLE, MULTIPART_STATUS, MultipartError, NODE_COMPRESSION_ENCODINGS, RESERVED_DEVICE_NAMES, compressNodeBytes, computeFileETag, createAssets, createCompression, createMultipart, createStatic, createUploadedFile, detectMIME, extractMultipartBoundary, isContainedPath, isDotfilePath, isMultipartError, isReservedDeviceName, isUnderPath, lookupContentType, matchesBytes, moveUploadedFile, parseMultipartRequest, parsePartHeaders, readUploadedFile, resolveContainedRealPath, resolveMultipartLimits, resolveStaticFallbackPath, resolveStaticPath, streamFile, streamUploadedFile, unlinkStagedFiles };
|
|
1357
1418
|
|
|
1358
1419
|
//# sourceMappingURL=index.js.map
|