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