@c9up/ream 0.2.28 → 0.2.30
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 +5 -6
- package/dist/Codemods.d.ts +24 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +211 -1
- package/dist/Codemods.js.map +1 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +17 -5
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +15 -2
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +33 -23
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +41 -11
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +21 -15
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +40 -58
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts +7 -4
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +12 -17
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +20 -1
- package/dist/bodyparser/qsParse.d.ts.map +1 -1
- package/dist/bodyparser/qsParse.js +78 -25
- package/dist/bodyparser/qsParse.js.map +1 -1
- package/dist/codemods/source.d.ts +57 -0
- package/dist/codemods/source.d.ts.map +1 -0
- package/dist/codemods/source.js +282 -0
- package/dist/codemods/source.js.map +1 -0
- package/dist/console/prompts.d.ts.map +1 -1
- package/dist/console/prompts.js +3 -1
- package/dist/console/prompts.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +61 -71
- package/dist/container/Container.js.map +1 -1
- package/dist/events/Emitter.d.ts +12 -0
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +36 -9
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/helix/helpers.d.ts.map +1 -1
- package/dist/events/helix/helpers.js +19 -9
- package/dist/events/helix/helpers.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +8 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +79 -12
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/MessageBuilder.d.ts +4 -2
- package/dist/helpers/MessageBuilder.d.ts.map +1 -1
- package/dist/helpers/MessageBuilder.js +4 -2
- package/dist/helpers/MessageBuilder.js.map +1 -1
- package/dist/helpers/errors.d.ts +11 -0
- package/dist/helpers/errors.d.ts.map +1 -0
- package/dist/helpers/errors.js +17 -0
- package/dist/helpers/errors.js.map +1 -0
- package/dist/helpers/napi-loader.d.ts.map +1 -1
- package/dist/helpers/napi-loader.js +3 -2
- package/dist/helpers/napi-loader.js.map +1 -1
- package/dist/http/Exception.d.ts +14 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +10 -2
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +31 -2
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +12 -5
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +9 -3
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +43 -37
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +42 -17
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +80 -58
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +7 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +14 -6
- package/dist/http/Response.js.map +1 -1
- package/dist/http/SseStream.js +6 -7
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/permissions.d.ts +25 -0
- package/dist/http/permissions.d.ts.map +1 -0
- package/dist/http/permissions.js +38 -0
- package/dist/http/permissions.js.map +1 -0
- package/dist/http-client/HttpClient.d.ts +111 -0
- package/dist/http-client/HttpClient.d.ts.map +1 -0
- package/dist/http-client/HttpClient.js +386 -0
- package/dist/http-client/HttpClient.js.map +1 -0
- package/dist/http-client/addresses.d.ts +14 -0
- package/dist/http-client/addresses.d.ts.map +1 -0
- package/dist/http-client/addresses.js +57 -0
- package/dist/http-client/addresses.js.map +1 -0
- package/dist/http-client/fake.d.ts +50 -0
- package/dist/http-client/fake.d.ts.map +1 -0
- package/dist/http-client/fake.js +95 -0
- package/dist/http-client/fake.js.map +1 -0
- package/dist/http-client/index.d.ts +7 -0
- package/dist/http-client/index.d.ts.map +1 -0
- package/dist/http-client/index.js +7 -0
- package/dist/http-client/index.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +6 -3
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/native/generated.d.ts +93 -36
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/profiler/Profiler.d.ts.map +1 -1
- package/dist/profiler/Profiler.js +4 -1
- package/dist/profiler/Profiler.js.map +1 -1
- package/dist/profiler/client.generated.d.ts +5 -0
- package/dist/profiler/client.generated.d.ts.map +1 -0
- package/dist/profiler/client.generated.js +6 -0
- package/dist/profiler/client.generated.js.map +1 -0
- package/dist/profiler/collectors.d.ts +92 -13
- package/dist/profiler/collectors.d.ts.map +1 -1
- package/dist/profiler/collectors.js +106 -19
- package/dist/profiler/collectors.js.map +1 -1
- package/dist/profiler/endpoint.d.ts +2 -1
- package/dist/profiler/endpoint.d.ts.map +1 -1
- package/dist/profiler/endpoint.js +25 -1
- package/dist/profiler/endpoint.js.map +1 -1
- package/dist/profiler/index.d.ts +8 -4
- package/dist/profiler/index.d.ts.map +1 -1
- package/dist/profiler/index.js +11 -4
- package/dist/profiler/index.js.map +1 -1
- package/dist/profiler/middleware.d.ts.map +1 -1
- package/dist/profiler/middleware.js +76 -10
- package/dist/profiler/middleware.js.map +1 -1
- package/dist/profiler/preview.d.ts +17 -0
- package/dist/profiler/preview.d.ts.map +1 -0
- package/dist/profiler/preview.js +100 -0
- package/dist/profiler/preview.js.map +1 -0
- package/dist/profiler/toolbar.d.ts +15 -23
- package/dist/profiler/toolbar.d.ts.map +1 -1
- package/dist/profiler/toolbar.js +25 -91
- package/dist/profiler/toolbar.js.map +1 -1
- package/dist/profiler/types.d.ts +6 -1
- package/dist/profiler/types.d.ts.map +1 -1
- package/dist/router/Router.d.ts +6 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +115 -12
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +6 -0
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +4 -1
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +27 -14
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +97 -40
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/Scheduler.js +1 -1
- package/dist/scheduler/Scheduler.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.js +13 -2
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +32 -8
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.d.ts.map +1 -1
- package/dist/security/SignedUrl.js +16 -19
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/server/Server.d.ts +44 -12
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +31 -10
- package/dist/server/Server.js.map +1 -1
- package/dist/server/nativeGrpc.d.ts +27 -0
- package/dist/server/nativeGrpc.d.ts.map +1 -0
- package/dist/server/nativeGrpc.js +47 -0
- package/dist/server/nativeGrpc.js.map +1 -0
- package/dist/server/nativeLimits.d.ts +57 -0
- package/dist/server/nativeLimits.d.ts.map +1 -0
- package/dist/server/nativeLimits.js +75 -0
- package/dist/server/nativeLimits.js.map +1 -0
- package/dist/session/Session.d.ts +12 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +13 -4
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +28 -9
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/config.d.ts +5 -0
- package/dist/session/config.d.ts.map +1 -1
- package/dist/session/config.js +8 -0
- package/dist/session/config.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +26 -7
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/errors.d.ts +17 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +21 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/templateTags.d.ts +14 -0
- package/dist/session/templateTags.d.ts.map +1 -1
- package/dist/session/templateTags.js +45 -17
- package/dist/session/templateTags.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +49 -27
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +7 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +16 -4
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.d.ts +1 -1
- package/dist/utils/Macroable.js +2 -2
- package/dist/utils/Macroable.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +11 -3
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Codemods.ts +314 -3
- package/src/HttpKernel.ts +18 -7
- package/src/Ignitor.ts +58 -26
- package/src/bodyparser/BodyParserMiddleware.ts +45 -16
- package/src/bodyparser/MultipartFile.ts +46 -67
- package/src/bodyparser/parseSize.ts +17 -17
- package/src/bodyparser/qsParse.ts +96 -26
- package/src/codemods/source.ts +303 -0
- package/src/console/prompts.ts +3 -1
- package/src/container/Container.ts +97 -81
- package/src/events/Emitter.ts +37 -10
- package/src/events/helix/helpers.ts +24 -19
- package/src/graphql/GraphQLEngine.ts +91 -14
- package/src/helpers/MessageBuilder.ts +4 -2
- package/src/helpers/errors.ts +16 -0
- package/src/helpers/napi-loader.ts +3 -2
- package/src/http/Exception.ts +17 -2
- package/src/http/HttpContext.ts +34 -5
- package/src/http/RedirectBuilder.ts +51 -37
- package/src/http/Request.ts +102 -58
- package/src/http/Response.ts +15 -7
- package/src/http/SseStream.ts +6 -7
- package/src/http/permissions.ts +47 -0
- package/src/http-client/HttpClient.ts +505 -0
- package/src/http-client/addresses.ts +58 -0
- package/src/http-client/fake.ts +141 -0
- package/src/http-client/index.ts +21 -0
- package/src/index.ts +6 -1
- package/src/middleware/Pipeline.ts +7 -3
- package/src/native/generated.ts +106 -48
- package/src/profiler/Profiler.ts +4 -1
- package/src/profiler/client/toolbar.css +595 -0
- package/src/profiler/client/toolbar.js +1299 -0
- package/src/profiler/client.generated.ts +7 -0
- package/src/profiler/collectors.ts +156 -19
- package/src/profiler/endpoint.ts +27 -1
- package/src/profiler/index.ts +17 -3
- package/src/profiler/middleware.ts +74 -9
- package/src/profiler/preview.ts +104 -0
- package/src/profiler/toolbar.ts +28 -91
- package/src/profiler/types.ts +6 -1
- package/src/router/Router.ts +136 -13
- package/src/rpc/RpcProvider.ts +10 -1
- package/src/rpc/RpcRouter.ts +129 -45
- package/src/scheduler/Scheduler.ts +1 -1
- package/src/scheduler/locks/RedisLockBackend.ts +12 -4
- package/src/security/CookieSigner.ts +30 -8
- package/src/security/SignedUrl.ts +18 -17
- package/src/server/Server.ts +60 -12
- package/src/server/nativeGrpc.ts +59 -0
- package/src/server/nativeLimits.ts +111 -0
- package/src/session/Session.ts +29 -4
- package/src/session/SessionMiddleware.ts +30 -10
- package/src/session/config.ts +7 -0
- package/src/session/drivers/MemoryDriver.ts +26 -7
- package/src/session/errors.ts +31 -0
- package/src/session/templateTags.ts +57 -0
- package/src/storage/StaticMiddleware.ts +46 -27
- package/src/testing/ApiResponse.ts +7 -1
- package/src/testing/TestClient.ts +14 -5
- package/src/utils/Macroable.ts +2 -2
- package/scripts/build-napi-types.mjs +0 -69
- package/scripts/copy-napi.mjs +0 -121
- package/scripts/generate-mime.mjs +0 -119
- package/scripts/generate-napi-types.mjs +0 -187
|
@@ -21,11 +21,11 @@ export interface BodyParserConfig {
|
|
|
21
21
|
*/
|
|
22
22
|
allowedMethods?: string[]
|
|
23
23
|
json?: {
|
|
24
|
-
limit?: string // e.g. '1mb'
|
|
24
|
+
limit?: string | number // e.g. '1mb', '1.5 mb' or a number of bytes
|
|
25
25
|
types?: string[]
|
|
26
26
|
}
|
|
27
27
|
form?: {
|
|
28
|
-
limit?: string
|
|
28
|
+
limit?: string | number
|
|
29
29
|
types?: string[]
|
|
30
30
|
/**
|
|
31
31
|
* Turn `''` into `null`. AdonisJS defaults this to `true`, so an empty
|
|
@@ -39,12 +39,12 @@ export interface BodyParserConfig {
|
|
|
39
39
|
queryString?: QsParseOptions
|
|
40
40
|
}
|
|
41
41
|
raw?: {
|
|
42
|
-
limit?: string
|
|
42
|
+
limit?: string | number
|
|
43
43
|
types?: string[]
|
|
44
44
|
}
|
|
45
45
|
multipart?: {
|
|
46
46
|
/** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
|
|
47
|
-
limit?: string
|
|
47
|
+
limit?: string | number
|
|
48
48
|
/**
|
|
49
49
|
* Maximum number of files per request. AdonisJS has no such option — it
|
|
50
50
|
* bounds a multipart body by total size and field count only, so ten
|
|
@@ -98,6 +98,8 @@ const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
|
|
|
98
98
|
|
|
99
99
|
export default class BodyParserMiddleware {
|
|
100
100
|
#config: ResolvedBodyParserConfig
|
|
101
|
+
/** Each section's limit in bytes, read once so a bad size fails at boot. */
|
|
102
|
+
readonly #limits: { json: number; form: number; raw: number; multipart: number }
|
|
101
103
|
|
|
102
104
|
constructor(explicitConfig: BodyParserConfig | undefined = undefined) {
|
|
103
105
|
// The default value is load-bearing: an OPTIONAL parameter still counts
|
|
@@ -118,6 +120,12 @@ export default class BodyParserMiddleware {
|
|
|
118
120
|
raw: { ...DEFAULT_CONFIG.raw, ...config?.raw },
|
|
119
121
|
multipart: { ...DEFAULT_CONFIG.multipart, ...config?.multipart },
|
|
120
122
|
}
|
|
123
|
+
this.#limits = {
|
|
124
|
+
json: parseSize(this.#config.json.limit),
|
|
125
|
+
form: parseSize(this.#config.form.limit),
|
|
126
|
+
raw: parseSize(this.#config.raw.limit),
|
|
127
|
+
multipart: parseSize(this.#config.multipart.limit),
|
|
128
|
+
}
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
async handle(ctx: HttpContext, next: () => Promise<void>) {
|
|
@@ -136,18 +144,22 @@ export default class BodyParserMiddleware {
|
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
const contentType = ctx.request.header('content-type') ?? ''
|
|
147
|
+
// A body over its native limit (`bodyTooLarge`) was never kept: `raw()`
|
|
148
|
+
// throws the same 413 below instead of handing back an empty string. The
|
|
149
|
+
// route is already matched here, so an unknown URL answered 404 first.
|
|
139
150
|
const rawBody = ctx.request.raw()
|
|
140
151
|
|
|
141
152
|
// Size check. Thrown rather than written: the app's exception handler
|
|
142
153
|
// negotiates the response, so an HTML app gets an error page instead of a
|
|
143
154
|
// JSON envelope it never asked for — and can override the rendering.
|
|
144
|
-
if (Buffer.byteLength(rawBody, 'utf8') >
|
|
155
|
+
if (Buffer.byteLength(rawBody, 'utf8') > this.#getLimit(contentType)) {
|
|
145
156
|
throw entityTooLarge('request entity too large')
|
|
146
157
|
}
|
|
147
158
|
|
|
148
|
-
// JSON
|
|
159
|
+
// JSON — parsed here and only here, under one of `json.types`. A body
|
|
160
|
+
// with any other media type (or none) is never read as JSON.
|
|
149
161
|
if (matchesType(contentType, this.#config.json.types)) {
|
|
150
|
-
|
|
162
|
+
ctx.request.setParsedBody(parseJsonBody(rawBody))
|
|
151
163
|
}
|
|
152
164
|
|
|
153
165
|
// Form URL-encoded
|
|
@@ -188,11 +200,11 @@ export default class BodyParserMiddleware {
|
|
|
188
200
|
await next()
|
|
189
201
|
}
|
|
190
202
|
|
|
191
|
-
#getLimit(contentType: string):
|
|
192
|
-
if (matchesType(contentType, this.#config.json.types)) return this.#
|
|
193
|
-
if (matchesType(contentType, this.#config.form.types)) return this.#
|
|
194
|
-
if (matchesType(contentType, this.#config.multipart.types)) return this.#
|
|
195
|
-
return this.#
|
|
203
|
+
#getLimit(contentType: string): number {
|
|
204
|
+
if (matchesType(contentType, this.#config.json.types)) return this.#limits.json
|
|
205
|
+
if (matchesType(contentType, this.#config.form.types)) return this.#limits.form
|
|
206
|
+
if (matchesType(contentType, this.#config.multipart.types)) return this.#limits.multipart
|
|
207
|
+
return this.#limits.raw
|
|
196
208
|
}
|
|
197
209
|
|
|
198
210
|
/**
|
|
@@ -214,12 +226,30 @@ export default class BodyParserMiddleware {
|
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
|
217
|
-
if (totalBytes >
|
|
229
|
+
if (totalBytes > this.#limits.multipart) {
|
|
218
230
|
throw entityTooLarge('request entity too large')
|
|
219
231
|
}
|
|
220
232
|
}
|
|
221
233
|
}
|
|
222
234
|
|
|
235
|
+
/**
|
|
236
|
+
* A JSON body as the request body: an object as is, any other JSON value under
|
|
237
|
+
* `_body` (the shape the raw-text parser uses), and `{}` for malformed JSON.
|
|
238
|
+
*/
|
|
239
|
+
function parseJsonBody(raw: string): Record<string, unknown> {
|
|
240
|
+
if (raw.length === 0) return {}
|
|
241
|
+
let parsed: unknown
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(raw)
|
|
244
|
+
} catch {
|
|
245
|
+
return {}
|
|
246
|
+
}
|
|
247
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
248
|
+
return { ...parsed }
|
|
249
|
+
}
|
|
250
|
+
return { _body: parsed }
|
|
251
|
+
}
|
|
252
|
+
|
|
223
253
|
/** The one exception every body-size limit raises (AdonisJS parity). */
|
|
224
254
|
function entityTooLarge(message: string): Exception {
|
|
225
255
|
return new Exception(message, { status: 413, code: 'E_REQUEST_ENTITY_TOO_LARGE' })
|
|
@@ -247,8 +277,7 @@ function matchesType(contentType: string, types: string[]): boolean {
|
|
|
247
277
|
|
|
248
278
|
/**
|
|
249
279
|
* `enabled` was an atlas-era invention: AdonisJS has no such flag, and a parser
|
|
250
|
-
* is turned off by giving it no `types`.
|
|
251
|
-
* `Request` lazy-parses on first read regardless.
|
|
280
|
+
* is turned off by giving it no `types`.
|
|
252
281
|
*
|
|
253
282
|
* Silently ignoring a config key that used to mean something would re-enable a
|
|
254
283
|
* parser an app deliberately switched off, so it fails loudly with the
|
|
@@ -275,7 +304,7 @@ function assertNoEnabledFlag(config?: BodyParserConfig): void {
|
|
|
275
304
|
throw new Error(
|
|
276
305
|
'[E_BODYPARSER_CONFIG] `multipart.tmpDir` is not supported — uploaded files are held in ' +
|
|
277
306
|
'memory and never written to a temporary directory. Choose the destination when you ' +
|
|
278
|
-
'store the file: `await file.
|
|
307
|
+
'store the file: `await file.move(directory)`.',
|
|
279
308
|
)
|
|
280
309
|
}
|
|
281
310
|
}
|
|
@@ -51,18 +51,27 @@ function isMimeType(value: string): value is MimeType {
|
|
|
51
51
|
*
|
|
52
52
|
* Like AdonisJS MultipartFile:
|
|
53
53
|
* const avatar = request.file('avatar', { size: '2mb', extnames: ['jpg', 'png'] })
|
|
54
|
-
* await avatar.
|
|
54
|
+
* await avatar.move(app.makePath('uploads'))
|
|
55
|
+
*
|
|
56
|
+
* Macroable: a package extends every upload at boot, the way a storage
|
|
57
|
+
* package adds `moveToDisk(key, disk?, options?)` to write it to a configured
|
|
58
|
+
* disk. `moveToDisk` is deliberately not defined here, so that macro owns it.
|
|
55
59
|
*/
|
|
56
60
|
|
|
57
61
|
import { randomBytes } from 'node:crypto'
|
|
58
62
|
import { Readable } from 'node:stream'
|
|
63
|
+
import { errorCode } from '../helpers/errors.js'
|
|
64
|
+
import { Macroable } from '../utils/Macroable.js'
|
|
65
|
+
import { parseSize } from './parseSize.js'
|
|
66
|
+
import { parseFormFields } from './qsParse.js'
|
|
59
67
|
|
|
60
68
|
export interface FileValidationOptions {
|
|
61
|
-
size
|
|
69
|
+
/** Largest accepted size: bytes, or a value such as `'2mb'` or `'1.5 mb'`. */
|
|
70
|
+
size?: string | number
|
|
62
71
|
extnames?: string[]
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
export class MultipartFile {
|
|
74
|
+
export class MultipartFile extends Macroable {
|
|
66
75
|
/** Original filename from the client. */
|
|
67
76
|
readonly clientName: string
|
|
68
77
|
|
|
@@ -153,6 +162,12 @@ export class MultipartFile {
|
|
|
153
162
|
/** The name it was written under, once it has been moved. */
|
|
154
163
|
fileName?: string
|
|
155
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Free-form data about the file, for whoever moves it: a storage macro
|
|
167
|
+
* records the URL of the stored object here.
|
|
168
|
+
*/
|
|
169
|
+
meta: Record<string, unknown> = {}
|
|
170
|
+
|
|
156
171
|
/** File size in bytes. */
|
|
157
172
|
readonly size: number
|
|
158
173
|
|
|
@@ -188,6 +203,7 @@ export class MultipartFile {
|
|
|
188
203
|
type: string
|
|
189
204
|
content: Buffer
|
|
190
205
|
}) {
|
|
206
|
+
super()
|
|
191
207
|
this.fieldName = options.fieldName
|
|
192
208
|
this.clientName = options.clientName
|
|
193
209
|
this.headers = { 'content-type': options.type }
|
|
@@ -272,8 +288,8 @@ export class MultipartFile {
|
|
|
272
288
|
* spells it.
|
|
273
289
|
*/
|
|
274
290
|
validate(options: FileValidationOptions = this.#validationOptions): boolean {
|
|
275
|
-
if (options.size) {
|
|
276
|
-
const maxBytes =
|
|
291
|
+
if (options.size !== undefined) {
|
|
292
|
+
const maxBytes = parseSize(options.size)
|
|
277
293
|
if (this.size > maxBytes) {
|
|
278
294
|
this.errors.push(`File size ${this.size} exceeds limit ${options.size}`)
|
|
279
295
|
}
|
|
@@ -318,40 +334,6 @@ export class MultipartFile {
|
|
|
318
334
|
return this.errors.length === 0
|
|
319
335
|
}
|
|
320
336
|
|
|
321
|
-
/**
|
|
322
|
-
* Move file to a directory on disk. When `name` is supplied it must be
|
|
323
|
-
* a plain filename — no path separators, no `.`/`..` segments. The
|
|
324
|
-
* common footgun is passing the client-supplied `clientName` directly
|
|
325
|
-
* (`await file.moveToDisk('uploads', file.clientName)`); a malicious
|
|
326
|
-
* upload could otherwise traverse out of `directory` via `../../etc/...`.
|
|
327
|
-
*/
|
|
328
|
-
async moveToDisk(directory: string, name?: string): Promise<string> {
|
|
329
|
-
const { mkdirSync, writeFileSync } = await import('node:fs')
|
|
330
|
-
const { join } = await import('node:path')
|
|
331
|
-
|
|
332
|
-
if (name !== undefined && !isSafeFilename(name)) {
|
|
333
|
-
throw new Error(
|
|
334
|
-
`MultipartFile.moveToDisk: 'name' must be a plain filename (no path separators, '.', or '..'). Got: ${JSON.stringify(name)}`,
|
|
335
|
-
)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
mkdirSync(directory, { recursive: true })
|
|
339
|
-
const fileName = name ?? `${randomBytes(16).toString('hex')}.${this.extname}`
|
|
340
|
-
// Defense-in-depth: the default branch interpolates `extname` (derived
|
|
341
|
-
// from attacker-controlled `clientName`) into the filename, bypassing
|
|
342
|
-
// the `name` guard above. Re-validate the synthesized fileName before
|
|
343
|
-
// join to reject a hostile extname (separators, NUL).
|
|
344
|
-
if (!isSafeFilename(fileName)) {
|
|
345
|
-
throw new Error(
|
|
346
|
-
`MultipartFile.moveToDisk: derived filename '${fileName}' is unsafe (separator or invalid char in clientName-derived extname).`,
|
|
347
|
-
)
|
|
348
|
-
}
|
|
349
|
-
const filePath = join(directory, fileName)
|
|
350
|
-
writeFileSync(filePath, this.content)
|
|
351
|
-
this.markAsMoved(fileName, filePath)
|
|
352
|
-
return filePath
|
|
353
|
-
}
|
|
354
|
-
|
|
355
337
|
/**
|
|
356
338
|
* Write the file into `location` (AdonisJS `move`).
|
|
357
339
|
*
|
|
@@ -360,19 +342,31 @@ export class MultipartFile {
|
|
|
360
342
|
* construction), so this writes the buffer. There is therefore no
|
|
361
343
|
* `E_MISSING_FILE_TMP_PATH`: there is no temporary file to be missing.
|
|
362
344
|
*
|
|
363
|
-
* `overwrite` defaults to true, as upstream.
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
345
|
+
* `overwrite` defaults to true, as upstream.
|
|
346
|
+
*
|
|
347
|
+
* NAMED DEVIATION — without `name`, upstream writes under `clientName`, a
|
|
348
|
+
* value the client chose; Ream generates a random name carrying the
|
|
349
|
+
* detected extension, so an upload neither picks its own path nor
|
|
350
|
+
* overwrites another one by sharing its name.
|
|
351
|
+
*
|
|
352
|
+
* A caller-supplied `name` must be a plain filename (no separator, no `.`
|
|
353
|
+
* or `..`): the common footgun is passing `file.clientName` straight
|
|
354
|
+
* through. The generated name is checked too, since its extension comes
|
|
355
|
+
* from the client's filename when the bytes are not recognised.
|
|
367
356
|
*/
|
|
368
357
|
async move(location: string, options?: { name?: string; overwrite?: boolean }): Promise<void> {
|
|
369
358
|
const { mkdir, writeFile } = await import('node:fs/promises')
|
|
370
359
|
const { join } = await import('node:path')
|
|
371
360
|
|
|
361
|
+
if (options?.name !== undefined && !isSafeFilename(options.name)) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`MultipartFile.move: 'name' must be a plain filename (no path separators, '.', or '..'). Got: ${JSON.stringify(options.name)}`,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
372
366
|
const name = options?.name ?? `${randomBytes(16).toString('hex')}.${this.extname || 'unknown'}`
|
|
373
367
|
if (!isSafeFilename(name)) {
|
|
374
368
|
throw new Error(
|
|
375
|
-
`MultipartFile.move:
|
|
369
|
+
`MultipartFile.move: the derived file name ${JSON.stringify(name)} is unsafe (a separator or NUL in the extension of the client's filename).`,
|
|
376
370
|
)
|
|
377
371
|
}
|
|
378
372
|
|
|
@@ -392,7 +386,7 @@ export class MultipartFile {
|
|
|
392
386
|
try {
|
|
393
387
|
await writeFile(filePath, this.content, options?.overwrite === false ? { flag: 'wx' } : {})
|
|
394
388
|
} catch (error) {
|
|
395
|
-
if ((error
|
|
389
|
+
if (errorCode(error) === 'EEXIST') {
|
|
396
390
|
throw new Error(
|
|
397
391
|
`"${name}" already exists at "${location}". Set "overwrite = true" to overwrite it`,
|
|
398
392
|
)
|
|
@@ -432,23 +426,6 @@ function isSafeFilename(name: string): boolean {
|
|
|
432
426
|
return true
|
|
433
427
|
}
|
|
434
428
|
|
|
435
|
-
function parseFileSize(size: string): number {
|
|
436
|
-
const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
|
|
437
|
-
if (!match) return 1024 * 1024
|
|
438
|
-
const [, digits, unit] = match
|
|
439
|
-
const num = parseInt(digits ?? '0', 10)
|
|
440
|
-
switch (unit?.toLowerCase()) {
|
|
441
|
-
case 'kb':
|
|
442
|
-
return num * 1024
|
|
443
|
-
case 'mb':
|
|
444
|
-
return num * 1024 * 1024
|
|
445
|
-
case 'gb':
|
|
446
|
-
return num * 1024 * 1024 * 1024
|
|
447
|
-
default:
|
|
448
|
-
return num
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
429
|
/**
|
|
453
430
|
* Hydrate `MultipartFile` instances from the typed payload the Rust
|
|
454
431
|
* HyperServer attaches to the request. The wire-level parser (multer crate,
|
|
@@ -464,11 +441,13 @@ export function hydrateMultipartPayload(payload: {
|
|
|
464
441
|
size: number
|
|
465
442
|
contentB64: string
|
|
466
443
|
}>
|
|
467
|
-
}): { fields: Record<string,
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
444
|
+
}): { fields: Record<string, unknown>; files: MultipartFile[] } {
|
|
445
|
+
// Nested and collected exactly like a urlencoded body: `tags[]` repeated is
|
|
446
|
+
// an array, `user[name]` an object. Flat, a repeated name kept only its
|
|
447
|
+
// last value once a file input was added to the form.
|
|
448
|
+
// The multipart part count is already bounded natively (`maxFields`), so
|
|
449
|
+
// no parameter cap applies on top of it here.
|
|
450
|
+
const fields = parseFormFields(payload.fields, { parameterLimit: Number.POSITIVE_INFINITY })
|
|
472
451
|
const files = payload.files.map(
|
|
473
452
|
(f) =>
|
|
474
453
|
new MultipartFile({
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
2
|
+
import { parse } from '../helpers/bytes.js'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
+
* Read a size limit (`'1mb'`, `'50 mb'`, `'1.5kb'`, `1024`) into bytes, with
|
|
6
|
+
* the `bytes` grammar every size option of the body parser accepts.
|
|
7
|
+
*
|
|
8
|
+
* Throws on anything it cannot read, or on a negative size. A silent fallback
|
|
9
|
+
* is what turned `limit: '50 mb'` into a 1 MB cap with no word: a limit the
|
|
10
|
+
* application did not set is worse than a boot that refuses to start.
|
|
5
11
|
*/
|
|
6
|
-
export function parseSize(size: string): number {
|
|
7
|
-
const
|
|
8
|
-
if (!
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return num * 1024
|
|
14
|
-
case 'mb':
|
|
15
|
-
return num * 1024 * 1024
|
|
16
|
-
case 'gb':
|
|
17
|
-
return num * 1024 * 1024 * 1024
|
|
18
|
-
default:
|
|
19
|
-
return num
|
|
12
|
+
export function parseSize(size: string | number): number {
|
|
13
|
+
const bytes = parse(size)
|
|
14
|
+
if (bytes === null || !Number.isFinite(bytes) || bytes < 0) {
|
|
15
|
+
throw new ReamError(
|
|
16
|
+
'E_BODYPARSER_INVALID_SIZE',
|
|
17
|
+
`Cannot read the size ${JSON.stringify(size)}: expected a number of bytes or a value such as '512kb', '1.5mb' or '2 gb'.`,
|
|
18
|
+
)
|
|
20
19
|
}
|
|
20
|
+
return bytes
|
|
21
21
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* dependency it can write in a screenful, and the limits below are easier to
|
|
12
12
|
* defend when they are visible.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Four guards, all of which `qs` also applies:
|
|
15
15
|
*
|
|
16
16
|
* - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
|
|
17
17
|
* classic prototype-pollution vector; the key never reaches an object.
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
* as a literal key rather than allocating unbounded nesting.
|
|
20
20
|
* - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
|
|
21
21
|
* million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
|
|
22
|
+
* - **Explicit array indices are capped** (`qs` `arrayLimit`, default 20).
|
|
23
|
+
* A larger index is an object key, so `ids[4294967294]=1` stays one entry.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
/** `qs` defaults, reproduced so the behaviour is the same out of the box. */
|
|
25
27
|
export const DEFAULT_DEPTH = 5
|
|
26
28
|
export const DEFAULT_PARAMETER_LIMIT = 1000
|
|
29
|
+
export const DEFAULT_ARRAY_LIMIT = 20
|
|
27
30
|
|
|
28
31
|
export interface QsParseOptions {
|
|
29
32
|
/**
|
|
@@ -34,6 +37,12 @@ export interface QsParseOptions {
|
|
|
34
37
|
allowDots?: boolean
|
|
35
38
|
depth?: number
|
|
36
39
|
parameterLimit?: number
|
|
40
|
+
/**
|
|
41
|
+
* Highest explicit index + 1 that still builds an array (`qs` defaults to
|
|
42
|
+
* 20). `ids[25]=x` becomes `{ ids: { '25': 'x' } }`, so a 16-byte request
|
|
43
|
+
* cannot allocate a 100-million-slot array.
|
|
44
|
+
*/
|
|
45
|
+
arrayLimit?: number
|
|
37
46
|
/** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
|
|
38
47
|
convertEmptyStringsToNull?: boolean
|
|
39
48
|
/** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
|
|
@@ -77,15 +86,53 @@ function isIndex(segment: string): boolean {
|
|
|
77
86
|
return /^\d+$/.test(segment)
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
type Container = Record<string, unknown> | unknown[]
|
|
90
|
+
|
|
91
|
+
function isContainer(value: unknown): value is Container {
|
|
92
|
+
return typeof value === 'object' && value !== null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `{ '0': a, '1': b }` from `[a, b]`, holes left out — what `qs` produces on overflow. */
|
|
96
|
+
function arrayToObject(array: unknown[]): Record<string, unknown> {
|
|
97
|
+
const object: Record<string, unknown> = {}
|
|
98
|
+
array.forEach((item, index) => {
|
|
99
|
+
object[String(index)] = item
|
|
100
|
+
})
|
|
101
|
+
return object
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Assign `value` at `segments` inside `target`, creating containers as needed.
|
|
106
|
+
*
|
|
107
|
+
* An explicit index at or above `arrayLimit` never addresses an array: the
|
|
108
|
+
* container becomes (or is converted into) an object keyed by that index,
|
|
109
|
+
* as `qs` does. Without it, `ids[100000000]=1` allocates a sparse array whose
|
|
110
|
+
* iteration or serialisation blocks the event loop for seconds. Appends
|
|
111
|
+
* (`tags[]`, a repeated key) stay arrays — each one costs a parameter, so
|
|
112
|
+
* `parameterLimit` already bounds them, and a long checkbox group keeps the
|
|
113
|
+
* shape its handler expects (`qs` turns those into objects past the limit).
|
|
114
|
+
*/
|
|
115
|
+
function assign(
|
|
116
|
+
target: Record<string, unknown>,
|
|
117
|
+
segments: string[],
|
|
118
|
+
value: unknown,
|
|
119
|
+
arrayLimit: number,
|
|
120
|
+
): void {
|
|
121
|
+
let cursor: Container = target
|
|
122
|
+
// Swaps `cursor` inside its parent when an array has to become an object.
|
|
123
|
+
let replace: (next: Container) => void = () => {}
|
|
83
124
|
for (let i = 0; i < segments.length; i++) {
|
|
84
125
|
const segment = segments[i] ?? ''
|
|
85
126
|
const last = i === segments.length - 1
|
|
86
127
|
|
|
87
128
|
if (FORBIDDEN_KEYS.has(segment)) return
|
|
88
129
|
|
|
130
|
+
if (Array.isArray(cursor) && isIndex(segment) && Number(segment) >= arrayLimit) {
|
|
131
|
+
const converted = arrayToObject(cursor)
|
|
132
|
+
replace(converted)
|
|
133
|
+
cursor = converted
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
if (last) {
|
|
90
137
|
if (segment === '') {
|
|
91
138
|
// `tags[]` — append.
|
|
@@ -109,24 +156,31 @@ function assign(target: Record<string, unknown>, segments: string[], value: unkn
|
|
|
109
156
|
}
|
|
110
157
|
|
|
111
158
|
const nextSegment = segments[i + 1] ?? ''
|
|
112
|
-
const wantsArray =
|
|
159
|
+
const wantsArray =
|
|
160
|
+
nextSegment === '' || (isIndex(nextSegment) && Number(nextSegment) < arrayLimit)
|
|
113
161
|
|
|
114
162
|
if (Array.isArray(cursor)) {
|
|
115
163
|
if (!isIndex(segment)) return
|
|
164
|
+
const array: unknown[] = cursor
|
|
116
165
|
const index = Number(segment)
|
|
117
|
-
const existing =
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
const existing: unknown = array[index]
|
|
167
|
+
const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
|
|
168
|
+
array[index] = child
|
|
169
|
+
replace = (next) => {
|
|
170
|
+
array[index] = next
|
|
120
171
|
}
|
|
121
|
-
cursor =
|
|
172
|
+
cursor = child
|
|
122
173
|
continue
|
|
123
174
|
}
|
|
124
175
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
176
|
+
const object: Record<string, unknown> = cursor
|
|
177
|
+
const existing: unknown = object[segment]
|
|
178
|
+
const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
|
|
179
|
+
object[segment] = child
|
|
180
|
+
replace = (next) => {
|
|
181
|
+
object[segment] = next
|
|
128
182
|
}
|
|
129
|
-
cursor =
|
|
183
|
+
cursor = child
|
|
130
184
|
}
|
|
131
185
|
}
|
|
132
186
|
|
|
@@ -153,27 +207,43 @@ export function parseQueryString(
|
|
|
153
207
|
body: string,
|
|
154
208
|
options: QsParseOptions = {},
|
|
155
209
|
): Record<string, unknown> {
|
|
156
|
-
|
|
210
|
+
if (!body) return {}
|
|
157
211
|
const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const pairs = body.split('&')
|
|
162
|
-
let seen = 0
|
|
163
|
-
for (const pair of pairs) {
|
|
212
|
+
const fields: Array<{ name: string; value: string }> = []
|
|
213
|
+
for (const pair of body.split('&')) {
|
|
164
214
|
if (!pair) continue
|
|
165
|
-
|
|
166
|
-
|
|
215
|
+
// Nothing past the limit is even decoded.
|
|
216
|
+
if (fields.length >= parameterLimit) break
|
|
167
217
|
const eqIdx = pair.indexOf('=')
|
|
168
218
|
const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx)
|
|
169
219
|
const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1)
|
|
220
|
+
fields.push({ name: decodeFormComponent(rawKey), value: decodeFormComponent(rawValue) })
|
|
221
|
+
}
|
|
222
|
+
return parseFormFields(fields, options)
|
|
223
|
+
}
|
|
170
224
|
|
|
171
|
-
|
|
172
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Nest form fields that arrive as decoded name/value pairs — a multipart
|
|
227
|
+
* body's text parts — with the key grammar and the guards of a urlencoded
|
|
228
|
+
* body. A `<select multiple name="tags[]">` keeps every value, and
|
|
229
|
+
* `user[name]` nests, whether or not the form also carries a file input.
|
|
230
|
+
*/
|
|
231
|
+
export function parseFormFields(
|
|
232
|
+
fields: Iterable<{ name: string; value: string }>,
|
|
233
|
+
options: QsParseOptions = {},
|
|
234
|
+
): Record<string, unknown> {
|
|
235
|
+
const depth = options.depth ?? DEFAULT_DEPTH
|
|
236
|
+
const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
|
|
237
|
+
const arrayLimit = options.arrayLimit ?? DEFAULT_ARRAY_LIMIT
|
|
238
|
+
const result: Record<string, unknown> = {}
|
|
239
|
+
|
|
240
|
+
let seen = 0
|
|
241
|
+
for (const field of fields) {
|
|
242
|
+
if (++seen > parameterLimit) break
|
|
243
|
+
let value: unknown = field.value
|
|
173
244
|
if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
|
|
174
245
|
if (options.convertEmptyStringsToNull && value === '') value = null
|
|
175
|
-
|
|
176
|
-
assign(result, splitKey(key, depth, options.allowDots ?? true), value)
|
|
246
|
+
assign(result, splitKey(field.name, depth, options.allowDots ?? true), value, arrayLimit)
|
|
177
247
|
}
|
|
178
248
|
return result
|
|
179
249
|
}
|