@znight/http 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/src/multipart.ts
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming `multipart/form-data` parser (RFC 7578, RFC 2046), shared by every adapter so that
|
|
3
|
+
* uploads behave the same on Node.js and Bun.
|
|
4
|
+
*
|
|
5
|
+
* The Web-standard `Request.formData()` is not used: it buffers the whole body before parsing, has
|
|
6
|
+
* no per-part limits, and its results differ between runtimes (default part type, `filename*`,
|
|
7
|
+
* escaped quotes, LF line endings, parts without name). This parser reads the body as it arrives
|
|
8
|
+
* and enforces the upload rules of the route (expected fields, file count, size and media type) on
|
|
9
|
+
* the fly, so an invalid upload is rejected as soon as the offending part shows up. Files are
|
|
10
|
+
* buffered in memory as standard `File` objects, within the limits.
|
|
11
|
+
*
|
|
12
|
+
* Status codes: malformed bodies, unexpected file fields and several files for a single-file field
|
|
13
|
+
* → 400; a file, a field, too many files or too many parts → 413; a refused media type → 415. Errors
|
|
14
|
+
* raised while the body is being read carry `connection: close` (the rest of the body is discarded).
|
|
15
|
+
*/
|
|
16
|
+
import type { HttpExceptionOptions } from './exceptions.ts'
|
|
17
|
+
import type { QueryRecord } from './query.ts'
|
|
18
|
+
|
|
19
|
+
import { Buffer } from 'node:buffer'
|
|
20
|
+
|
|
21
|
+
import { BadRequestException, issue, PayloadTooLargeException, UnsupportedMediaTypeException } from './exceptions.ts'
|
|
22
|
+
import { matchesMediaType, mediaTypeEssence, OCTET_STREAM } from './mime.ts'
|
|
23
|
+
import { formatByteSize } from './size.ts'
|
|
24
|
+
import { emptyRecord } from './utils.ts'
|
|
25
|
+
|
|
26
|
+
/** Constraints of the files received by one upload parameter (`@UploadedFile` / `@UploadedFiles`). */
|
|
27
|
+
export interface FileRule {
|
|
28
|
+
/** Field name; `undefined`: the files of every field. */
|
|
29
|
+
readonly field: string | undefined
|
|
30
|
+
/** `@UploadedFiles` (an array) rather than `@UploadedFile` (a single file). */
|
|
31
|
+
readonly multiple: boolean
|
|
32
|
+
/** Maximum size of each file, in bytes. */
|
|
33
|
+
readonly maxSize: number
|
|
34
|
+
/** Maximum number of files (1 for a single file). */
|
|
35
|
+
readonly maxFiles: number
|
|
36
|
+
/** Accepted media types (`image/png`, `image/*`); `undefined`: any. */
|
|
37
|
+
readonly mimeTypes: readonly string[] | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How a multipart body is read. */
|
|
41
|
+
export interface MultipartPolicy {
|
|
42
|
+
/**
|
|
43
|
+
* Upload rules of the route (files are only accepted in their fields: a file in another field is
|
|
44
|
+
* rejected with 400, unless a rule has no field). Without rules, every file is accepted without
|
|
45
|
+
* limit (but the body limit).
|
|
46
|
+
*/
|
|
47
|
+
readonly rules: readonly FileRule[]
|
|
48
|
+
/** Maximum number of parts (text fields and files). */
|
|
49
|
+
readonly maxParts: number
|
|
50
|
+
/** Maximum size of a text field, in bytes. */
|
|
51
|
+
readonly maxFieldSize: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A parsed multipart body. */
|
|
55
|
+
export interface MultipartResult {
|
|
56
|
+
/** Text fields (repeated names → arrays), without prototype. */
|
|
57
|
+
readonly fields: QueryRecord
|
|
58
|
+
/** Files by field name, without prototype. */
|
|
59
|
+
readonly files: Record<string, File[]>
|
|
60
|
+
/** Every file, in body order. */
|
|
61
|
+
readonly list: readonly File[]
|
|
62
|
+
/** Every entry, in body order. */
|
|
63
|
+
readonly entries: readonly (readonly [string, string | File])[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Default maximum size of the headers of one part. */
|
|
67
|
+
const MAX_HEADER_SIZE = 16 * 1024
|
|
68
|
+
/** Maximum transport padding (spaces) after a boundary delimiter. */
|
|
69
|
+
const MAX_PADDING = 256
|
|
70
|
+
/** Errors raised while the body is still being read ask the client to close the connection. */
|
|
71
|
+
const CLOSE: HttpExceptionOptions = Object.freeze({ headers: Object.freeze({ connection: 'close' }) })
|
|
72
|
+
|
|
73
|
+
const CR = 13
|
|
74
|
+
const LF = 10
|
|
75
|
+
const DASH = 45
|
|
76
|
+
const SPACE = 32
|
|
77
|
+
const TAB = 9
|
|
78
|
+
const HEADER_END = Buffer.from('\r\n\r\n')
|
|
79
|
+
const EMPTY: Buffer = Buffer.alloc(0)
|
|
80
|
+
const DECODER = new TextDecoder()
|
|
81
|
+
|
|
82
|
+
const PRINTABLE = /^[\u0020-\u007e]*$/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A received file. Its `type` is the media type declared by the client, lower-cased (empty when it
|
|
86
|
+
* contains characters outside printable ASCII), identical on every runtime (Bun's `File` adds
|
|
87
|
+
* `;charset=utf-8` to some text types).
|
|
88
|
+
*/
|
|
89
|
+
function createFile(chunks: Uint8Array[], name: string, type: string): File {
|
|
90
|
+
const file = new File(chunks, name, { type })
|
|
91
|
+
const normalized = PRINTABLE.test(type) ? type.toLowerCase() : ''
|
|
92
|
+
if (file.type !== normalized) Object.defineProperty(file, 'type', { value: normalized, configurable: true })
|
|
93
|
+
return file
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Description of a part, from its headers. */
|
|
97
|
+
export interface PartInfo {
|
|
98
|
+
readonly name: string
|
|
99
|
+
/** `undefined` for text fields. */
|
|
100
|
+
readonly filename: string | undefined
|
|
101
|
+
readonly contentType: string | undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Receives the content of one part. */
|
|
105
|
+
export interface PartSink {
|
|
106
|
+
data(chunk: Uint8Array): void
|
|
107
|
+
end(): void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Receives the parts found by {@link MultipartParser}. */
|
|
111
|
+
export interface PartHandler {
|
|
112
|
+
start(part: PartInfo): PartSink
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type State = 'preamble' | 'delimiter' | 'headers' | 'body' | 'done'
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Incremental parser: feed it with {@link write} as chunks arrive, then call {@link end}. Errors
|
|
119
|
+
* (malformed body, limits enforced by the handler) are thrown synchronously from `write`/`end`.
|
|
120
|
+
*/
|
|
121
|
+
export class MultipartParser {
|
|
122
|
+
readonly #delimiter: Buffer
|
|
123
|
+
readonly #handler: PartHandler
|
|
124
|
+
readonly #maxHeaderSize: number
|
|
125
|
+
/** The body is conceptually prefixed with CRLF so that every delimiter is `CRLF--boundary`. */
|
|
126
|
+
#buffer: Buffer = Buffer.from('\r\n')
|
|
127
|
+
#state: State = 'preamble'
|
|
128
|
+
#sink: PartSink | undefined
|
|
129
|
+
/** Bytes of the header block already searched for its end (avoids rescanning on every chunk). */
|
|
130
|
+
#scanned = 0
|
|
131
|
+
|
|
132
|
+
constructor(boundary: string, handler: PartHandler, maxHeaderSize: number = MAX_HEADER_SIZE) {
|
|
133
|
+
this.#delimiter = Buffer.from(`\r\n--${boundary}`, 'latin1')
|
|
134
|
+
this.#handler = handler
|
|
135
|
+
this.#maxHeaderSize = maxHeaderSize
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `true` once the closing delimiter was read (what follows is the epilogue, ignored). */
|
|
139
|
+
get done(): boolean {
|
|
140
|
+
return this.#state === 'done'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
write(chunk: Uint8Array): void {
|
|
144
|
+
if (this.#state === 'done' || chunk.byteLength === 0) return
|
|
145
|
+
const view = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
146
|
+
this.#buffer = this.#buffer.length === 0 ? view : Buffer.concat([this.#buffer, view])
|
|
147
|
+
this.#process()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
end(): void {
|
|
151
|
+
if (this.#state !== 'done') throw malformed('unexpected end of the body (missing closing boundary)')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#process(): void {
|
|
155
|
+
for (;;) {
|
|
156
|
+
switch (this.#state) {
|
|
157
|
+
case 'preamble': {
|
|
158
|
+
const index = this.#buffer.indexOf(this.#delimiter)
|
|
159
|
+
if (index === -1) {
|
|
160
|
+
this.#keepTail()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
this.#buffer = this.#buffer.subarray(index + this.#delimiter.length)
|
|
164
|
+
this.#state = 'delimiter'
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
case 'delimiter':
|
|
168
|
+
if (!this.#afterDelimiter()) return
|
|
169
|
+
break
|
|
170
|
+
case 'headers':
|
|
171
|
+
if (!this.#headers()) return
|
|
172
|
+
break
|
|
173
|
+
case 'body':
|
|
174
|
+
if (!this.#body()) return
|
|
175
|
+
break
|
|
176
|
+
default:
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** After a delimiter: `--` (closing delimiter) or optional padding then CRLF (next part). */
|
|
183
|
+
#afterDelimiter(): boolean {
|
|
184
|
+
const buffer = this.#buffer
|
|
185
|
+
if (buffer.length < 2) return false
|
|
186
|
+
if (buffer[0] === DASH && buffer[1] === DASH) {
|
|
187
|
+
this.#state = 'done'
|
|
188
|
+
this.#buffer = EMPTY
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
let index = 0
|
|
192
|
+
while (index < buffer.length && (buffer[index] === SPACE || buffer[index] === TAB)) index++
|
|
193
|
+
if (index > MAX_PADDING) throw malformed('invalid boundary delimiter')
|
|
194
|
+
if (index + 1 >= buffer.length) return false
|
|
195
|
+
if (buffer[index] !== CR || buffer[index + 1] !== LF) throw malformed('invalid boundary delimiter')
|
|
196
|
+
this.#buffer = buffer.subarray(index + 2)
|
|
197
|
+
this.#state = 'headers'
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#headers(): boolean {
|
|
202
|
+
const buffer = this.#buffer
|
|
203
|
+
if (buffer.length < 2) return false
|
|
204
|
+
let end: number
|
|
205
|
+
let next: number
|
|
206
|
+
if (buffer[0] === CR && buffer[1] === LF) {
|
|
207
|
+
end = 0
|
|
208
|
+
next = 2
|
|
209
|
+
} else {
|
|
210
|
+
end = buffer.indexOf(HEADER_END, Math.max(0, this.#scanned - (HEADER_END.length - 1)))
|
|
211
|
+
if (end === -1) {
|
|
212
|
+
if (buffer.length > this.#maxHeaderSize) throw malformed('the headers of a part are too large')
|
|
213
|
+
this.#scanned = buffer.length
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
if (end > this.#maxHeaderSize) throw malformed('the headers of a part are too large')
|
|
217
|
+
next = end + 4
|
|
218
|
+
}
|
|
219
|
+
const part = parsePartHeaders(buffer.toString('utf8', 0, end))
|
|
220
|
+
this.#scanned = 0
|
|
221
|
+
this.#buffer = buffer.subarray(next)
|
|
222
|
+
this.#sink = this.#handler.start(part)
|
|
223
|
+
this.#state = 'body'
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#body(): boolean {
|
|
228
|
+
const buffer = this.#buffer
|
|
229
|
+
const sink = this.#sink as PartSink
|
|
230
|
+
const index = buffer.indexOf(this.#delimiter)
|
|
231
|
+
if (index === -1) {
|
|
232
|
+
// keep what may be the beginning of a delimiter
|
|
233
|
+
const safe = buffer.length - (this.#delimiter.length - 1)
|
|
234
|
+
if (safe > 0) {
|
|
235
|
+
sink.data(buffer.subarray(0, safe))
|
|
236
|
+
this.#buffer = buffer.subarray(safe)
|
|
237
|
+
}
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
if (index > 0) sink.data(buffer.subarray(0, index))
|
|
241
|
+
this.#sink = undefined
|
|
242
|
+
sink.end()
|
|
243
|
+
this.#buffer = buffer.subarray(index + this.#delimiter.length)
|
|
244
|
+
this.#state = 'delimiter'
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#keepTail(): void {
|
|
249
|
+
const keep = this.#delimiter.length - 1
|
|
250
|
+
if (this.#buffer.length > keep) this.#buffer = this.#buffer.subarray(this.#buffer.length - keep)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -------------------------------------------------------------------------------------------------
|
|
255
|
+
// Headers
|
|
256
|
+
// -------------------------------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function parsePartHeaders(block: string): PartInfo {
|
|
259
|
+
let disposition: string | undefined
|
|
260
|
+
let contentType: string | undefined
|
|
261
|
+
let current: 'content-disposition' | 'content-type' | undefined
|
|
262
|
+
if (block !== '') {
|
|
263
|
+
for (const line of block.split('\r\n')) {
|
|
264
|
+
if (line.startsWith(' ') || line.startsWith('\t')) {
|
|
265
|
+
// obsolete line folding: continuation of the previous header
|
|
266
|
+
if (current === 'content-disposition') disposition = `${disposition ?? ''} ${line.trim()}`
|
|
267
|
+
else if (current === 'content-type') contentType = `${contentType ?? ''} ${line.trim()}`
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const colon = line.indexOf(':')
|
|
271
|
+
if (colon <= 0) throw malformed('invalid part header')
|
|
272
|
+
const name = line.slice(0, colon).trim().toLowerCase()
|
|
273
|
+
const value = line.slice(colon + 1).trim()
|
|
274
|
+
current = undefined
|
|
275
|
+
if (name === 'content-disposition' && disposition === undefined) {
|
|
276
|
+
disposition = value
|
|
277
|
+
current = name
|
|
278
|
+
} else if (name === 'content-type' && contentType === undefined) {
|
|
279
|
+
contentType = value
|
|
280
|
+
current = name
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (disposition === undefined) throw malformed('a part has no Content-Disposition header')
|
|
285
|
+
const { type, params } = parseParameters(disposition)
|
|
286
|
+
if (type !== 'form-data') throw malformed('a part is not "form-data"')
|
|
287
|
+
const name = params.get('name')
|
|
288
|
+
if (name === undefined) throw malformed('a part has no name')
|
|
289
|
+
const extended = params.get('filename*')
|
|
290
|
+
const plain = params.get('filename')
|
|
291
|
+
const filename = (extended === undefined ? undefined : decodeExtendedValue(extended)) ?? plain
|
|
292
|
+
return {
|
|
293
|
+
name: decodeFormName(name),
|
|
294
|
+
filename: filename === undefined ? undefined : decodeFormName(filename),
|
|
295
|
+
contentType: contentType === '' ? undefined : contentType,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Parses `value; key=token; key="quoted \" string"` (Content-Disposition, Content-Type). Keys are
|
|
301
|
+
* lower-cased; the first occurrence of a key wins.
|
|
302
|
+
*/
|
|
303
|
+
export function parseParameters(value: string): { readonly type: string; readonly params: Map<string, string> } {
|
|
304
|
+
const params = new Map<string, string>()
|
|
305
|
+
let index = value.indexOf(';')
|
|
306
|
+
const type = (index === -1 ? value : value.slice(0, index)).trim().toLowerCase()
|
|
307
|
+
while (index !== -1 && index < value.length) {
|
|
308
|
+
index++ // skip ';'
|
|
309
|
+
while (index < value.length && isSpace(value.charCodeAt(index))) index++
|
|
310
|
+
if (index >= value.length) break
|
|
311
|
+
if (value[index] === ';') continue // empty parameter
|
|
312
|
+
const equals = value.indexOf('=', index)
|
|
313
|
+
if (equals === -1) throw malformed(`invalid parameter in ${JSON.stringify(truncate(value))}`)
|
|
314
|
+
const key = value.slice(index, equals).trim().toLowerCase()
|
|
315
|
+
index = equals + 1
|
|
316
|
+
while (index < value.length && isSpace(value.charCodeAt(index))) index++
|
|
317
|
+
let parameter: string
|
|
318
|
+
if (value[index] === '"') {
|
|
319
|
+
parameter = ''
|
|
320
|
+
index++
|
|
321
|
+
for (;;) {
|
|
322
|
+
if (index >= value.length) throw malformed('unterminated quoted string in a part header')
|
|
323
|
+
const char = value[index] as string
|
|
324
|
+
if (char === '"') {
|
|
325
|
+
index++
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
if (char === '\\' && (value[index + 1] === '"' || value[index + 1] === '\\')) {
|
|
329
|
+
parameter += value[index + 1]
|
|
330
|
+
index += 2
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
parameter += char
|
|
334
|
+
index++
|
|
335
|
+
}
|
|
336
|
+
while (index < value.length && isSpace(value.charCodeAt(index))) index++
|
|
337
|
+
if (index < value.length && value[index] !== ';') throw malformed('invalid quoted string in a part header')
|
|
338
|
+
} else {
|
|
339
|
+
const next = value.indexOf(';', index)
|
|
340
|
+
parameter = (next === -1 ? value.slice(index) : value.slice(index, next)).trim()
|
|
341
|
+
index = next === -1 ? value.length : next
|
|
342
|
+
}
|
|
343
|
+
if (key !== '' && !params.has(key)) params.set(key, parameter)
|
|
344
|
+
if (index >= value.length) break
|
|
345
|
+
}
|
|
346
|
+
return { type, params }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isSpace(code: number): boolean {
|
|
350
|
+
return code === SPACE || code === TAB
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Names and filenames: browsers escape `"`, CR and LF as `%22`, `%0D` and `%0A` (HTML standard). */
|
|
354
|
+
function decodeFormName(value: string): string {
|
|
355
|
+
return value.includes('%')
|
|
356
|
+
? value.replace(/%(22|0D|0A)/gi, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
357
|
+
: value
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** RFC 5987 `charset'language'percent-encoded` (`filename*`); `undefined` when it cannot be decoded. */
|
|
361
|
+
function decodeExtendedValue(value: string): string | undefined {
|
|
362
|
+
const match = /^([\w!#$%&+^`{}~-]+)'[\w-]*'(.*)$/.exec(value)
|
|
363
|
+
if (match === null) return undefined
|
|
364
|
+
const charset = (match[1] as string).toLowerCase()
|
|
365
|
+
const encoded = match[2] as string
|
|
366
|
+
try {
|
|
367
|
+
if (charset === 'utf-8') return decodeURIComponent(encoded)
|
|
368
|
+
if (charset === 'iso-8859-1') {
|
|
369
|
+
return encoded.replace(/%([0-9a-f]{2})/gi, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
return undefined
|
|
373
|
+
}
|
|
374
|
+
return undefined
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The `boundary` parameter of a `multipart/form-data` content type (400 when missing or invalid). */
|
|
378
|
+
export function multipartBoundary(contentType: string): string {
|
|
379
|
+
let boundary: string | undefined
|
|
380
|
+
try {
|
|
381
|
+
boundary = parseParameters(contentType).params.get('boundary')
|
|
382
|
+
} catch {
|
|
383
|
+
boundary = undefined
|
|
384
|
+
}
|
|
385
|
+
if (boundary === undefined || boundary === '') throw malformed('missing boundary in the content type')
|
|
386
|
+
// RFC 2046: 1 to 70 printable ASCII characters
|
|
387
|
+
if (boundary.length > 70 || [...boundary].some(char => char < ' ' || char > '~')) {
|
|
388
|
+
throw malformed('invalid boundary in the content type')
|
|
389
|
+
}
|
|
390
|
+
return boundary
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// -------------------------------------------------------------------------------------------------
|
|
394
|
+
// Collecting fields and files
|
|
395
|
+
// -------------------------------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
/** Applies a {@link MultipartPolicy} to the parts and collects fields and files. */
|
|
398
|
+
class Collector implements PartHandler {
|
|
399
|
+
readonly fields: QueryRecord = emptyRecord()
|
|
400
|
+
readonly files: Record<string, File[]> = emptyRecord()
|
|
401
|
+
readonly list: File[] = []
|
|
402
|
+
readonly entries: (readonly [string, string | File])[] = []
|
|
403
|
+
readonly #policy: MultipartPolicy
|
|
404
|
+
readonly #named: ReadonlyMap<string, FileRule>
|
|
405
|
+
readonly #any: FileRule | undefined
|
|
406
|
+
readonly #counts = new Map<FileRule, number>()
|
|
407
|
+
#parts = 0
|
|
408
|
+
|
|
409
|
+
constructor(policy: MultipartPolicy) {
|
|
410
|
+
this.#policy = policy
|
|
411
|
+
const named = new Map<string, FileRule>()
|
|
412
|
+
let any: FileRule | undefined
|
|
413
|
+
for (const rule of policy.rules) {
|
|
414
|
+
if (rule.field === undefined) any = rule
|
|
415
|
+
else named.set(rule.field, rule)
|
|
416
|
+
}
|
|
417
|
+
this.#named = named
|
|
418
|
+
this.#any = any
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
start(part: PartInfo): PartSink {
|
|
422
|
+
this.#parts++
|
|
423
|
+
if (this.#parts > this.#policy.maxParts) {
|
|
424
|
+
throw new PayloadTooLargeException(
|
|
425
|
+
`Too many parts in the multipart body (at most ${this.#policy.maxParts})`,
|
|
426
|
+
{ maxParts: this.#policy.maxParts },
|
|
427
|
+
CLOSE,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
return part.filename === undefined ? this.#field(part.name) : this.#file(part, part.filename)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#field(name: string): PartSink {
|
|
434
|
+
const limit = this.#policy.maxFieldSize
|
|
435
|
+
const chunks: Uint8Array[] = []
|
|
436
|
+
let size = 0
|
|
437
|
+
return {
|
|
438
|
+
data: chunk => {
|
|
439
|
+
size += chunk.byteLength
|
|
440
|
+
if (size > limit) {
|
|
441
|
+
throw new PayloadTooLargeException(
|
|
442
|
+
`Field ${JSON.stringify(truncate(name))} exceeds the maximum size of ${formatByteSize(limit)}`,
|
|
443
|
+
{ field: name, maxSize: limit },
|
|
444
|
+
CLOSE,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
chunks.push(chunk)
|
|
448
|
+
},
|
|
449
|
+
end: () => {
|
|
450
|
+
const value = DECODER.decode(chunks.length === 1 ? (chunks[0] as Uint8Array) : Buffer.concat(chunks))
|
|
451
|
+
const existing = this.fields[name]
|
|
452
|
+
if (existing === undefined) this.fields[name] = value
|
|
453
|
+
else if (typeof existing === 'string') this.fields[name] = [existing, value]
|
|
454
|
+
else existing.push(value)
|
|
455
|
+
this.entries.push([name, value])
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#file(part: PartInfo, filename: string): PartSink {
|
|
461
|
+
const type = part.contentType === undefined ? OCTET_STREAM : part.contentType
|
|
462
|
+
const name = part.name
|
|
463
|
+
let rules: readonly FileRule[] = []
|
|
464
|
+
let admitted = false
|
|
465
|
+
// an empty <input type="file"> is sent as an empty part without filename: it is not a file
|
|
466
|
+
const admit = (): void => {
|
|
467
|
+
admitted = true
|
|
468
|
+
rules = this.#admit(name, type)
|
|
469
|
+
}
|
|
470
|
+
if (filename !== '') admit()
|
|
471
|
+
const chunks: Uint8Array[] = []
|
|
472
|
+
let size = 0
|
|
473
|
+
return {
|
|
474
|
+
data: chunk => {
|
|
475
|
+
if (!admitted) admit()
|
|
476
|
+
size += chunk.byteLength
|
|
477
|
+
for (const rule of rules) {
|
|
478
|
+
if (size > rule.maxSize) {
|
|
479
|
+
throw new PayloadTooLargeException(
|
|
480
|
+
`File in field ${JSON.stringify(truncate(name))} exceeds the maximum size of ${formatByteSize(rule.maxSize)}`,
|
|
481
|
+
{ field: name, maxSize: rule.maxSize },
|
|
482
|
+
CLOSE,
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
chunks.push(chunk)
|
|
487
|
+
},
|
|
488
|
+
end: () => {
|
|
489
|
+
if (!admitted) return
|
|
490
|
+
const file = createFile(chunks, filename, type)
|
|
491
|
+
const list = this.files[name]
|
|
492
|
+
if (list === undefined) this.files[name] = [file]
|
|
493
|
+
else list.push(file)
|
|
494
|
+
this.list.push(file)
|
|
495
|
+
this.entries.push([name, file])
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Rules applying to a file of `field` (400 for an unexpected field), with count and type checks. */
|
|
501
|
+
#admit(field: string, type: string): readonly FileRule[] {
|
|
502
|
+
if (this.#policy.rules.length === 0) return []
|
|
503
|
+
const rules: FileRule[] = []
|
|
504
|
+
const named = this.#named.get(field)
|
|
505
|
+
if (named !== undefined) rules.push(named)
|
|
506
|
+
if (this.#any !== undefined) rules.push(this.#any)
|
|
507
|
+
if (rules.length === 0) {
|
|
508
|
+
const expected = [...this.#named.keys()]
|
|
509
|
+
throw new BadRequestException(
|
|
510
|
+
`Unexpected file field ${JSON.stringify(truncate(field))}`,
|
|
511
|
+
issue(field, `Unexpected file field (expected ${expected.join(', ')})`),
|
|
512
|
+
CLOSE,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
for (const rule of rules) {
|
|
516
|
+
const count = (this.#counts.get(rule) ?? 0) + 1
|
|
517
|
+
this.#counts.set(rule, count)
|
|
518
|
+
if (count > rule.maxFiles) {
|
|
519
|
+
const where = rule.field === undefined ? '' : ` in field ${JSON.stringify(truncate(rule.field))}`
|
|
520
|
+
if (!rule.multiple) {
|
|
521
|
+
throw new BadRequestException(`Expected a single file${where}`, issue(field, 'Expected a single file'), CLOSE)
|
|
522
|
+
}
|
|
523
|
+
throw new PayloadTooLargeException(
|
|
524
|
+
`Too many files${where} (at most ${rule.maxFiles})`,
|
|
525
|
+
{ field, maxFiles: rule.maxFiles },
|
|
526
|
+
CLOSE,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
if (rule.mimeTypes !== undefined && !matchesMediaType(type, rule.mimeTypes)) {
|
|
530
|
+
throw new UnsupportedMediaTypeException(
|
|
531
|
+
`Unsupported file type ${JSON.stringify(truncate(mediaTypeEssence(type)))} in field ${JSON.stringify(truncate(field))} (accepted: ${rule.mimeTypes.join(', ')})`,
|
|
532
|
+
{ field, type: mediaTypeEssence(type), accepted: rule.mimeTypes },
|
|
533
|
+
CLOSE,
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return rules
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
result(): MultipartResult {
|
|
541
|
+
return { fields: this.fields, files: this.files, list: this.list, entries: this.entries }
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Reads a multipart body from `stream` with `policy`. The stream is cancelled on error (the rest of
|
|
547
|
+
* the body is discarded).
|
|
548
|
+
*
|
|
549
|
+
* @throws BadRequestException (400) for a malformed body, too many parts / files or an unexpected
|
|
550
|
+
* file field; PayloadTooLargeException (413) for a file or field above its limit;
|
|
551
|
+
* UnsupportedMediaTypeException (415) for a file of a refused type.
|
|
552
|
+
*/
|
|
553
|
+
export async function readMultipart(
|
|
554
|
+
stream: ReadableStream<Uint8Array>,
|
|
555
|
+
contentType: string,
|
|
556
|
+
policy: MultipartPolicy,
|
|
557
|
+
): Promise<MultipartResult> {
|
|
558
|
+
const reader = stream.getReader()
|
|
559
|
+
try {
|
|
560
|
+
const collector = new Collector(policy)
|
|
561
|
+
const parser = new MultipartParser(multipartBoundary(contentType), collector)
|
|
562
|
+
for (;;) {
|
|
563
|
+
const { done, value } = await reader.read()
|
|
564
|
+
if (done) break
|
|
565
|
+
parser.write(value)
|
|
566
|
+
}
|
|
567
|
+
parser.end()
|
|
568
|
+
return collector.result()
|
|
569
|
+
} catch (error) {
|
|
570
|
+
await reader.cancel(error).catch(() => undefined)
|
|
571
|
+
throw error
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Parses a buffered multipart body (see {@link readMultipart}). */
|
|
576
|
+
export function parseMultipart(bytes: Uint8Array, contentType: string, policy: MultipartPolicy): MultipartResult {
|
|
577
|
+
const collector = new Collector(policy)
|
|
578
|
+
const parser = new MultipartParser(multipartBoundary(contentType), collector)
|
|
579
|
+
parser.write(bytes)
|
|
580
|
+
parser.end()
|
|
581
|
+
return collector.result()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Converts a parse result to a `FormData` (entries in body order). */
|
|
585
|
+
export function toFormData(result: MultipartResult): FormData {
|
|
586
|
+
const form = new FormData()
|
|
587
|
+
for (const [name, value] of result.entries) form.append(name, value)
|
|
588
|
+
return form
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function malformed(reason: string): BadRequestException {
|
|
592
|
+
return new BadRequestException(`Invalid multipart body: ${reason}`, undefined, CLOSE)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function truncate(value: string): string {
|
|
596
|
+
return value.length > 100 ? `${value.slice(0, 100)}…` : value
|
|
597
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Errors of invalid `HttpPlugin` options (internal). */
|
|
2
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
3
|
+
|
|
4
|
+
/** `ZNIGHT005: HttpPlugin: option "<name>" must be <expected> (received <value>)`. */
|
|
5
|
+
export function invalidOption(
|
|
6
|
+
name: string,
|
|
7
|
+
expected: string,
|
|
8
|
+
value: unknown,
|
|
9
|
+
cause?: unknown,
|
|
10
|
+
hints?: readonly string[],
|
|
11
|
+
): ZnightError {
|
|
12
|
+
return new ZnightError(
|
|
13
|
+
ErrorCode.InvalidDecorator,
|
|
14
|
+
`HttpPlugin: option "${name}" must be ${expected} (received ${describeOption(value)})`,
|
|
15
|
+
{
|
|
16
|
+
...(cause === undefined ? {} : { cause }),
|
|
17
|
+
...(hints === undefined ? {} : { hints }),
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A short description of an option value for messages. */
|
|
23
|
+
export function describeOption(value: unknown): string {
|
|
24
|
+
if (typeof value === 'function') return 'a function'
|
|
25
|
+
if (value instanceof RegExp) return String(value)
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(value) ?? String(value)
|
|
28
|
+
} catch {
|
|
29
|
+
return typeof value
|
|
30
|
+
}
|
|
31
|
+
}
|